From 3de4014366a01973808022d87a619580aa6ccb35 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:43:40 +0000 Subject: [PATCH 01/26] chore(stack): bump storage to v1.83.0-r0 (#7160) This bumps storage from v1.82.1-r0 to v1.83.0-r0. https://github.com/supabase/slim-services/releases/tag/storage-v1.83.0-r0 Planned after supabase/slim-services published v1.83.0-r0. This branch is rewritten from develop whenever a newer relevant release arrives. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- apps/cli/src/shared/services/Dockerfile | 2 +- packages/stack/src/Artifacts.ts | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 563c96ccd7..e79bd0d623 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -35,7 +35,7 @@ FROM supabase/gotrue:v2.197.0 AS gotrue FROM supabase/realtime:v2.143.4 AS realtime -FROM supabase/storage-api:v1.82.1 AS storage +FROM supabase/storage-api:v1.83.0 AS storage FROM supabase/logflare:1.53.0 AS logflare # One-shot job images (not started as long-running containers); differ's alias diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index 563c96ccd7..e79bd0d623 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -35,7 +35,7 @@ FROM supabase/gotrue:v2.197.0 AS gotrue FROM supabase/realtime:v2.143.4 AS realtime -FROM supabase/storage-api:v1.82.1 AS storage +FROM supabase/storage-api:v1.83.0 AS storage FROM supabase/logflare:1.53.0 AS logflare # One-shot job images (not started as long-running containers); differ's alias diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 3ad3e92387..3f66ad0cd3 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -267,23 +267,23 @@ const definitions: Readonly> = { storage: definition( "storage", { - upstreamVersion: "v1.82.1", + upstreamVersion: "v1.83.0", revision: 0, image: - "ghcr.io/supabase/cli/storage:v1.82.1-r0@sha256:68c0f36557430219eb318df22ee817915992f2d5c976971e32574c3fe429be3c", - upstreamImage: "supabase/storage-api:v1.82.1", + "ghcr.io/supabase/cli/storage:v1.83.0-r0@sha256:57846ea3e88af033b3518731e7eef8d99eea9c0d151a57e95f58fccb019304d3", + upstreamImage: "supabase/storage-api:v1.83.0", natives: { "darwin-arm64": { - archive: "69a3308f1633db400bc718ee363aaa3d0d8ae846f52af4ea3a61d4cf04064e69", - manifest: "37ac0f369085e88952fde5c7493e2291d9e1034ade6c763509900e4cafa7d978", + archive: "fc69a80e16b70fe56ea25232189c7241c9073625a6a8e0aecda57c0e06ac1dbc", + manifest: "ce54efaa1bba647b3742441ab580d6f23454a0bcec3f6dfeee9129a45c53f240", }, "linux-amd64": { - archive: "f8716410f5997cf2a89cd3fe13565fe21b6bb239ef0be9818be385c0b397afba", - manifest: "ca82ac18ccccd91ec3370d65497c7f17c938f8601d7982a5fb48893ad5c1b159", + archive: "43a22b349e55a887eb54204c5ead98bd89396a43373f1383dadcca75d9400bc8", + manifest: "b3ef4e252455b568b325be6290d400bd9ef75868ee49e74278ccde441a05cdaa", }, "linux-arm64": { - archive: "45e623ac2913dc0aa2e11c3b58ea437227c51bc8e55a5184a2736fb1c13ab49e", - manifest: "024d11f58d90d54ebe62f55b88f75d5b0e541f1612211f5bf6a351472c6cb466", + archive: "b8dc2e2167514f6fefff02e6a63b674ba3c7e1cb0f40438f035edbfee79bf57e", + manifest: "7925a314dd5ee2d1c037b8de60201c5c4cd75ae5ca9a5b6fc48d208a5bc4d676", }, }, }, From 401fde6886246fa1ffb2db5530bdc7bff1e1eac4 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 14 Oct 2026 17:33:55 +0000 Subject: [PATCH 02/26] fix(cli): route every management api call through the typed client (#7123) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Every Management API call in `apps/cli` now goes through the typed `@supabase/api` client. Each call gets an `api.operation` span named after its OpenAPI operation ID, so telemetry can tell which operations a command really calls. It complements the permission mapping added in #7105, which declares the operations each module calls. Before this PR, a dozen call sites built URLs by hand. Those calls skipped the span, response decoding, and the client's retry and error handling. Some of them existed because the generated client couldn't express what they needed, so this PR fixes the generator and client first, then moves the call sites, then adds a lint rule so new raw URLs fail CI. ```mermaid flowchart LR subgraph before["Before"] direction TB H1["command handler"] -->|"typed call"| C1["@supabase/api"] H2["sso / snippets / advisors /
postgres-config / db query / login …"] -->|"hand-built /v1/... URL"| X1["HttpClient"] C1 --> X1 X1 --> API1[("Management API")] end subgraph after["After"] direction TB H3["every command handler
and shared module"] -->|"api.v1.op(input, options?)"| C2["@supabase/api
span · decode · retry"] C2 --> X2["HttpClient"] X2 --> API2[("Management API")] L["lint rule
supabase/no-raw-management-api-url"] -. "blocks new raw URLs" .-> H3 end ``` ## What changes ### 1. Generator and client (`packages/api`) Four gaps forced callers back to raw HTTP: | Gap | Fix | | --- | --- | | Response enums were closed, so a new server value broke decoding | Multi-value response enums are open: `"ACTIVE" \| "PAUSED" \| (string & {})`. Single-value enums stay closed. | | Unknown request keys were dropped without an error | Request input decodes with `onExcessProperty: "error"`, so a typo fails as `SupabaseApiInputError` before any request is sent | | `const` schemas weren't generated | `const` maps to a literal | | One client meant one host and one token | Every wrapper takes an optional second argument: `{ baseUrl?, accessToken?, headers? }` | The last row is what let login and SSO profile reconciliation drop raw HTTP: ```ts // before: login built its own request because the client had no token yet const response = yield* httpClient.execute( HttpClientRequest.get(`${apiHost}/v1/profile`).pipe( HttpClientRequest.setHeader("Authorization", `Bearer ${token}`), ), ); // after const api = yield* makeApiClient({ baseUrl: apiHost, accessToken: token, userAgent }); const profile = yield* api.v1.getProfile(); ``` ```ts // per-request override on an existing client (sso update against a reconciled profile) yield* api.v1.updateASsoProvider(input, { baseUrl: profile.apiUrl, accessToken: profile.token }); ``` **Retries now back off.** The client used to retry immediately. It now uses exponential backoff with full jitter: ```mermaid xychart-beta title "Maximum delay before each retry (ms, full jitter picks 0 to max)" x-axis ["retry 1", "retry 2", "retry 3", "retry 4", "retry 5"] y-axis "ms" 0 --> 4500 bar [250, 500, 1000, 2000, 4000] ``` | Response | Retried for | Delay | | --- | --- | --- | | `429` | every method | `Retry-After` if present, otherwise backoff | | `5xx` / transport error | `GET` `PUT` `DELETE` `HEAD` only | `Retry-After` if present, otherwise backoff | | other `4xx` | never | — | `POST` and `PATCH` aren't retried on 5xx because they aren't idempotent. A `Retry-After` longer than 10 s isn't waited out: the response goes straight back to the caller. Each retry records `http.request.resend_count` on the span. A single request can override the policy with `retry: { maxRetries, requestTimeoutMs }`: ```ts // functions deploy runs its own rate-limit loop, so the client stays out of it api.executeRaw(operationDefinitions.v1DeployAFunction, input, { retry: { maxRetries: 0 } }); ``` | Caller | Override | Why | | --- | --- | --- | | `functions deploy` upload and bulk update | `maxRetries: 0` | the command's own `429` loop is the only retry layer | | `postgres-config update/delete` PUT | `maxRetries: 0` | a resent write with `restart_database` restarts the database twice | | linked-project cache fill at exit | `maxRetries: 0` | exit isn't held up when the API is failing | | `db query --linked` | `requestTimeoutMs: Infinity` | long queries keep running with no client timeout, as before | | `sso add` / `sso update` | `requestTimeoutMs: Infinity` | the server may fetch `--metadata-url` during the request; no client timeout, as before | ### 2. Self-validating OpenAPI overrides `openapi-overrides.json` patches upstream spec bugs. Patches used to fail silently once upstream fixed the bug, so stale patches piled up. The loader now rejects: - a `replace` without a `test` on the same path directly before it, so the patch states what it expects upstream to say; - a `replace` whose value already matches upstream, because the fix has landed upstream; - a `remove` whose target doesn't exist; - any entry without a `$comment` explaining the upstream bug. ```jsonc { "$comment": "Hostnames stored before the upstream fix can lack `status`; the fix covers only new writes.", "op": "test", "path": "/components/schemas/.../required", "value": ["status", "..."] }, { "op": "replace", "path": "/components/schemas/.../required", "value": ["..."] } ``` With these checks, the overrides go from 37 entries to 25. The removed patches were ones upstream has since fixed, plus a lenient removal of the v2 webhook paths, which now generate normally. ### 3. Call sites moved to the typed client (`apps/cli`) | Area | Before | After | | --- | --- | --- | | `sso add` / `sso update` | string URLs, hand-written response views | `createASsoProvider`, `getASsoProvider`, `updateASsoProvider`, per-request host and token for reconciled profiles. Structured output still prints the server body unchanged. | | `db query` | raw `POST /database/query` | `v1RunAQuery` | | `postgres-config get/update/delete` | raw GET and PUT | GET keeps the raw record (stored keys may predate the schema). PUT validates the keys the user passed against the same strict schema the platform enforces. Stored overrides are resent unvalidated through `executeRaw`'s `extraBody`, so one this CLI version doesn't know is kept. The returned record prints unchanged. | | `snippets list/download` | raw | typed operation. `--output json/yaml/toml` still prints the server body unchanged. | | `db advisors` (linked) | raw | `getSecurityAdvisors`, `getPerformanceAdvisors` | | `config push` cost check | raw | `listProjectAddons` | | login profile, upgrade suggestion, linked-project cache | raw | typed calls. Each builds its own client for its host and token. | | `projects list` / `projects create` | typed client's `executeRaw` | unchanged: `executeRaw` keeps fields the schema doesn't model | The shared `raw-http.ts` helper is deleted. One raw call remains, the device-login poll under `/platform/cli/login`. That route isn't in the Management API spec, so no typed operation exists. It now URL-encodes its parameters and has a commented lint exception. ### 4. Lint rule: `supabase/no-raw-management-api-url` A local oxlint JS plugin (`tools/oxlint/supabase-plugin.js`) reports any string or template literal in `apps/cli/src` (tests excluded) that starts a `/v1/…`, `/v2/…` or `/platform/…` path, including path constants that are joined to a base URL later. Service-scoped paths such as `/auth/v1/` and `/storage/v1/` don't match. ```ts const url = `${apiHost}/v1/projects/${ref}/config/database/postgres`; // ^ supabase/no-raw-management-api-url: Build Management API requests with the // typed `@supabase/api` client (`api.v1.*`, `execute`/`executeRaw`) so the call // emits the `api.operation` span; do not assemble `/v1/`, `/v2/` or `/platform/` URLs by hand. ``` ### 5. cli-e2e placeholder ref Recorded fixtures used `__PROJECT_REF__` as the project ref, which the typed client rejects as a path parameter. That kept login, upgrade suggestion and the linked-project cache on raw HTTP. Fixtures now use `replayfixtureprojref`, a 20-character ref that passes the check, and the replay server no longer rewrites placeholders back and forth. This explains the large fixture rename in the diff. Injected `429`/`5xx` replay responses send `Retry-After: 0`, so the new backoff doesn't slow down the suite. ## User-visible behavior changes Most of these come from validating input locally and decoding responses strictly: - `postgres-config update` and `config push` reject keys and values the platform would refuse before sending a request. An unknown `--config` key reports `unknown Postgres config parameter(s): …` and suggests upgrading the CLI if the parameter is new. Stored overrides are never checked locally. An invalid value reports `invalid Postgres config value for : …`, and a bare number for a size or duration suggests adding a unit (`work_mem=4096kB`). - The SSO attribute-mapping file is decoded strictly, so unknown keys are an error. The `sso` table shows only modelled fields. - `notebooks` rejects an unknown `view`. - `functions deploy` rejects an unknown function status. Unknown custom-domain statuses are shown as returned. - `snippets` output follows the spec's nullability (`description: null` instead of a missing field). - `db query` error output caps the response body and strips control characters. - Idempotent requests now retry on `5xx` and transport errors with backoff. `429` is retried for every method, except where a command opts out (see the overrides table above). --- .oxlintrc.json | 10 + CONTRIBUTING.md | 2 +- apps/cli-e2e/AGENTS.md | 12 +- .../2.response.json | 2 +- .../default.response.json | 2 +- .../default.request.json | 2 +- .../default.response.json | 0 .../2.request.json | 2 +- .../2.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 4 +- .../default.request.json | 2 +- .../default.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../2.request.json | 2 +- .../2.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../2.request.json | 2 +- .../2.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../GET_storage_v1_bucket/2.response.json | 2 +- .../default.response.json | 2 +- .../default.response.json | 2 +- .../default.request.json | 2 +- .../default.response.json | 0 .../GET_v1_organizations/2.response.json | 8 +- .../GET_v1_organizations/3.response.json | 8 +- .../GET_v1_organizations/4.response.json | 8 +- .../default.response.json | 8 +- .../2.request.json | 2 +- .../2.response.json | 0 .../3.request.json | 2 +- .../3.response.json | 0 .../4.request.json | 2 +- .../4.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../recorded/GET_v1_projects/2.response.json | 20 +- .../recorded/GET_v1_projects/3.response.json | 20 +- .../recorded/GET_v1_projects/4.response.json | 20 +- .../recorded/GET_v1_projects/5.response.json | 30 +- .../GET_v1_projects/default.response.json | 20 +- .../default.request.json | 12 - .../default.request.json | 12 - .../2.request.json | 12 - .../3.request.json | 12 - .../default.request.json | 12 - .../default.request.json | 12 - .../default.request.json | 12 - .../default.request.json | 12 - .../default.request.json | 12 - .../2.request.json | 12 - .../3.request.json | 12 - .../default.request.json | 12 - .../default.request.json | 12 - .../default.request.json | 12 - .../2.request.json | 12 - .../default.request.json | 12 - .../2.request.json | 12 - .../default.request.json | 12 - .../default.request.json | 12 - .../2.request.json | 2 +- .../2.response.json | 10 +- .../3.request.json | 2 +- .../3.response.json | 10 +- .../4.request.json | 2 +- .../4.response.json | 10 +- .../default.request.json | 2 +- .../default.response.json | 10 +- .../2.request.json | 12 + .../2.response.json | 0 .../default.request.json | 12 + .../default.response.json | 0 .../2.request.json | 2 +- .../2.response.json | 0 .../3.request.json | 2 +- .../3.response.json | 0 .../default.request.json | 12 + .../default.response.json | 0 .../2.request.json | 2 +- .../2.response.json | 0 .../3.request.json | 2 +- .../3.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../2.request.json | 2 +- .../2.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../2.request.json | 2 +- .../2.response.json | 0 .../default.request.json | 12 + .../default.response.json | 0 .../default.request.json | 12 + .../default.response.json | 2 +- .../2.request.json | 2 +- .../2.response.json | 1 + .../3.request.json | 2 +- .../3.response.json | 1 + .../default.request.json | 12 + .../default.response.json | 1 + .../2.request.json | 12 + .../2.response.json | 0 .../3.request.json | 12 + .../3.response.json | 0 .../default.request.json | 12 + .../default.response.json | 0 .../2.request.json | 12 + .../2.response.json | 0 .../3.request.json | 12 + .../3.response.json | 0 .../4.request.json | 2 +- .../4.response.json | 0 .../5.request.json | 2 +- .../5.response.json | 0 .../6.request.json | 2 +- .../6.response.json | 0 .../7.request.json | 2 +- .../7.response.json | 0 .../default.request.json | 12 + .../default.response.json | 0 .../default.request.json | 12 + .../default.response.json | 8 +- .../2.request.json | 2 +- .../2.response.json | 0 .../3.request.json | 2 +- .../3.response.json | 0 .../default.request.json | 12 + .../default.response.json | 0 .../2.request.json | 2 +- .../2.response.json | 0 .../3.request.json | 2 +- .../3.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 2 +- .../default.request.json | 12 + .../default.response.json | 0 .../2.request.json | 12 + .../2.response.json | 0 .../3.request.json | 2 +- .../3.response.json | 0 .../default.request.json | 12 + .../default.response.json | 0 .../2.request.json | 2 +- .../2.response.json | 0 .../3.request.json | 2 +- .../3.response.json | 0 .../4.request.json | 2 +- .../4.response.json | 0 .../5.request.json | 2 +- .../5.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../default.request.json | 12 + .../default.response.json | 0 .../2.request.json | 2 +- .../2.response.json | 0 .../3.request.json | 2 +- .../3.response.json | 0 .../default.request.json | 12 + .../default.response.json | 0 .../2.request.json | 2 +- .../2.response.json | 0 .../3.request.json | 2 +- .../3.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../2.request.json | 2 +- .../2.response.json | 0 .../3.request.json | 2 +- .../3.response.json | 0 .../4.request.json | 2 +- .../4.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../default.request.json | 12 + .../default.response.json | 0 .../GET_v1_snippets/default.request.json | 2 +- .../default.request.json | 12 - .../default.request.json | 2 +- .../default.response.json | 2 +- .../default.request.json | 2 +- .../default.response.json | 1 + .../default.request.json | 2 +- .../default.response.json | 0 .../default.response.json | 2 +- .../default.response.json | 2 +- .../default.response.json | 2 +- .../default.response.json | 2 +- .../default.response.json | 2 +- .../default.response.json | 2 +- .../default.response.json | 2 +- .../default.response.json | 2 +- .../2.response.json | 2 +- .../default.response.json | 2 +- .../default.response.json | 2 +- .../POST_v1_organizations/2.response.json | 4 +- .../default.response.json | 4 +- .../recorded/POST_v1_projects/2.request.json | 2 +- .../recorded/POST_v1_projects/2.response.json | 8 +- .../POST_v1_projects/default.request.json | 2 +- .../POST_v1_projects/default.response.json | 8 +- .../default.request.json | 2 +- .../default.response.json | 2 +- .../default.request.json | 2 +- .../default.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../2.request.json | 2 +- .../2.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../2.request.json | 2 +- .../2.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../2.request.json | 2 +- .../2.response.json | 0 .../3.request.json | 2 +- .../3.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../2.request.json | 2 +- .../2.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../2.request.json | 2 +- .../2.response.json | 0 .../3.request.json | 2 +- .../3.response.json | 0 .../4.request.json | 2 +- .../4.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../2.request.json | 2 +- .../2.response.json | 0 .../default.request.json | 2 +- .../default.response.json | 0 .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 4 +- .../interactions.json | 4 +- .../interactions.json | 4 +- .../interactions.json | 6 +- .../interactions.json | 2 +- .../interactions.json | 10 +- .../interactions.json | 6 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 4 +- .../interactions.json | 6 +- .../interactions.json | 6 +- .../interactions.json | 8 +- .../interactions.json | 8 +- .../interactions.json | 6 +- .../interactions.json | 6 +- .../interactions.json | 16 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 12 +- .../interactions.json | 12 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 4 +- .../interactions.json | 2 +- .../interactions.json | 2 +- .../interactions.json | 2 +- .../interactions.json | 2 +- .../interactions.json | 4 +- .../interactions.json | 2 +- .../interactions.json | 4 +- .../interactions.json | 2 +- .../interactions.json | 4 +- .../interactions.json | 2 +- .../interactions.json | 2 +- .../interactions.json | 4 +- .../interactions.json | 2 +- .../interactions.json | 4 +- .../interactions.json | 4 +- .../interactions.json | 4 +- .../interactions.json | 2 +- .../interactions.json | 4 +- .../interactions.json | 2 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 2 +- .../interactions.json | 2 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 2 +- .../interactions.json | 2 +- .../interactions.json | 10 +- .../interactions.json | 35 +- .../interactions.json | 10 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 2 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 2 +- .../interactions.json | 14 +- .../interactions.json | 4 +- .../interactions.json | 8 +- .../interactions.json | 8 +- .../interactions.json | 8 +- .../interactions.json | 16 +- .../interactions.json | 16 +- .../interactions.json | 16 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 16 +- .../interactions.json | 14 +- .../interactions.json | 16 +- .../interactions.json | 16 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 22 +- .../interactions.json | 4 +- .../interactions.json | 32 +- .../interactions.json | 32 +- .../interactions.json | 32 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 2 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 16 +- .../interactions.json | 6 +- .../interactions.json | 26 +- .../interactions.json | 13 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 2 +- .../interactions.json | 2 +- .../interactions.json | 14 +- .../interactions.json | 2 +- .../interactions.json | 2 +- .../interactions.json | 2 +- .../interactions.json | 12 +- .../interactions.json | 12 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 2 +- .../interactions.json | 2 +- .../interactions.json | 2 +- .../interactions.json | 2 +- .../interactions.json | 4 +- .../interactions.json | 2 +- .../interactions.json | 4 +- .../interactions.json | 4 +- .../interactions.json | 4 +- .../interactions.json | 2 +- .../interactions.json | 2 +- .../interactions.json | 2 +- .../interactions.json | 2 +- .../interactions.json | 2 +- .../interactions.json | 2 +- .../interactions.json | 4 +- .../interactions.json | 4 +- .../interactions.json | 6 +- .../interactions.json | 14 +- .../interactions.json | 2 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- .../interactions.json | 14 +- apps/cli-e2e/src/server/placeholder.ts | 39 +- apps/cli-e2e/src/server/replay-server.ts | 43 +- apps/cli-e2e/src/tests/domains.e2e.test.ts | 7 +- apps/cli-e2e/src/tests/env.ts | 13 +- apps/cli-e2e/src/tests/orgs.e2e.test.ts | 4 +- apps/cli-e2e/src/tests/projects.e2e.test.ts | 2 +- apps/cli-e2e/tests/setup.ts | 4 +- .../command-internal/go-output.encoders.ts | 4 +- apps/cli/src/command-internal/http-errors.ts | 4 +- .../login-api.layer.integration.test.ts | 38 +- .../src/command-internal/login-api.layer.ts | 29 +- .../command-internal/project-create-core.ts | 4 +- apps/cli/src/command-internal/raw-http.ts | 20 - .../src/command-internal/upgrade-suggest.ts | 76 +- .../upgrade-suggest.unit.test.ts | 31 +- ...ootstrap.workdir-cache.integration.test.ts | 20 +- .../update/update.integration.test.ts | 5 +- .../src/commands/config/push/SIDE_EFFECTS.md | 2 +- .../commands/config/push/push.cost-matrix.ts | 109 +- .../src/commands/config/push/push.e2e.test.ts | 7 +- .../config/push/push.integration.test.ts | 32 +- .../src/commands/db/advisors/SIDE_EFFECTS.md | 6 +- .../commands/db/advisors/advisors.format.ts | 194 +- .../db/advisors/advisors.format.unit.test.ts | 200 +- .../commands/db/advisors/advisors.handler.ts | 8 +- .../db/advisors/advisors.integration.test.ts | 132 +- .../commands/db/advisors/advisors.layers.ts | 11 +- .../commands/db/advisors/advisors.linked.ts | 159 +- .../cli/src/commands/db/query/SIDE_EFFECTS.md | 2 +- .../src/commands/db/query/query.handler.ts | 88 +- .../db/query/query.integration.test.ts | 170 +- .../src/commands/domains/domains.format.ts | 5 +- .../deploy/deploy.integration.test.ts | 62 +- .../functions/list/list.integration.test.ts | 1 - .../network-restrictions.cidr.ts | 2 +- .../commands/notebooks/notebooks.shared.ts | 20 +- .../postgres-config/delete/SIDE_EFFECTS.md | 7 +- .../postgres-config/delete/delete.handler.ts | 34 +- .../postgres-config/get/SIDE_EFFECTS.md | 4 +- .../postgres-config/postgres-config.errors.ts | 14 +- .../postgres-config.integration.test.ts | 427 +- .../postgres-config/postgres-config.shared.ts | 227 +- .../postgres-config/update/SIDE_EFFECTS.md | 8 +- .../postgres-config/update/update.handler.ts | 34 +- .../create/create.integration.test.ts | 13 +- .../commands/projects/list/list.handler.ts | 5 +- .../projects/list/list.integration.test.ts | 12 +- .../src/commands/projects/projects.format.ts | 5 +- .../seed/buckets/buckets.integration.test.ts | 12 +- .../snippets/download/download.handler.ts | 83 +- .../download/download.integration.test.ts | 36 +- .../commands/snippets/list/list.handler.ts | 119 +- .../snippets/list/list.integration.test.ts | 124 +- apps/cli/src/commands/sso/add/SIDE_EFFECTS.md | 14 +- apps/cli/src/commands/sso/add/add.handler.ts | 195 +- .../commands/sso/add/add.integration.test.ts | 59 +- apps/cli/src/commands/sso/sso.errors.ts | 5 +- apps/cli/src/commands/sso/sso.format.ts | 61 +- .../src/commands/sso/sso.format.unit.test.ts | 16 - apps/cli/src/commands/sso/sso.saml.ts | 35 +- .../src/commands/sso/update/SIDE_EFFECTS.md | 17 +- .../src/commands/sso/update/update.handler.ts | 320 +- .../sso/update/update.integration.test.ts | 263 +- .../vanity-subdomains.integration.test.ts | 22 +- apps/cli/src/shared/compute/compute-api.ts | 10 +- apps/cli/src/shared/functions/deploy.ts | 70 +- apps/cli/src/shared/functions/download.ts | 4 +- .../src/shared/services/services.shared.ts | 2 +- .../services/services.shared.unit.test.ts | 17 +- .../linked-project-cache.integration.test.ts | 10 + .../telemetry/linked-project-cache.layer.ts | 56 +- apps/cli/tests/helpers/command-mocks.ts | 14 +- apps/cli/tests/helpers/compute.ts | 20 +- .../tests/helpers/permission-real-layers.ts | 25 +- .../no-raw-management-api-url.unit.test.ts | 99 + packages/api/scripts/download-openapi.ts | 119 +- .../api/scripts/download-openapi.unit.test.ts | 106 +- packages/api/scripts/generate.ts | 241 +- packages/api/scripts/generate.unit.test.ts | 121 +- packages/api/scripts/openapi-overrides.json | 639 +- packages/api/src/effect.ts | 3 +- packages/api/src/effect.unit.test.ts | 29 +- packages/api/src/generated/contracts.ts | 5502 ++++- packages/api/src/generated/effect-client.ts | 1620 +- packages/api/src/generated/openapi.json | 17109 +++++++++++++++- packages/api/src/internal/client.ts | 262 +- packages/api/src/internal/client.unit.test.ts | 675 +- tools/oxlint/supabase-plugin.js | 61 + 498 files changed, 26942 insertions(+), 6000 deletions(-) rename apps/cli-e2e/fixtures/recorded/{DELETE_v1_branches___PROJECT_REF__ => DELETE_v1_branches_replayfixtureprojref}/default.request.json (82%) rename apps/cli-e2e/fixtures/recorded/{DELETE_v1_projects___PROJECT_REF___functions_hello_world => DELETE_v1_branches_replayfixtureprojref}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{DELETE_v1_projects___PROJECT_REF__ => DELETE_v1_projects_replayfixtureprojref}/2.request.json (82%) rename apps/cli-e2e/fixtures/recorded/{DELETE_v1_projects___PROJECT_REF__ => DELETE_v1_projects_replayfixtureprojref}/2.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{DELETE_v1_projects___PROJECT_REF__ => DELETE_v1_projects_replayfixtureprojref}/default.request.json (82%) rename apps/cli-e2e/fixtures/recorded/{DELETE_v1_projects___PROJECT_REF__ => DELETE_v1_projects_replayfixtureprojref}/default.response.json (80%) rename apps/cli-e2e/fixtures/recorded/{DELETE_v1_projects___PROJECT_REF___vanity_subdomain => DELETE_v1_projects_replayfixtureprojref_custom_hostname}/default.request.json (77%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___custom_hostname_reverify => DELETE_v1_projects_replayfixtureprojref_custom_hostname}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{DELETE_v1_projects___PROJECT_REF___custom_hostname => DELETE_v1_projects_replayfixtureprojref_functions_hello_world}/default.request.json (76%) rename apps/cli-e2e/fixtures/recorded/{DELETE_v1_branches___PROJECT_REF__ => DELETE_v1_projects_replayfixtureprojref_functions_hello_world}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{DELETE_v1_projects___PROJECT_REF___network_bans => DELETE_v1_projects_replayfixtureprojref_network_bans}/2.request.json (85%) rename apps/cli-e2e/fixtures/recorded/{DELETE_v1_projects___PROJECT_REF___network_bans => DELETE_v1_projects_replayfixtureprojref_network_bans}/2.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{DELETE_v1_projects___PROJECT_REF___network_bans => DELETE_v1_projects_replayfixtureprojref_network_bans}/default.request.json (85%) rename apps/cli-e2e/fixtures/recorded/{DELETE_v1_projects___PROJECT_REF___secrets => DELETE_v1_projects_replayfixtureprojref_network_bans}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{DELETE_v1_projects___PROJECT_REF___secrets => DELETE_v1_projects_replayfixtureprojref_secrets}/2.request.json (84%) rename apps/cli-e2e/fixtures/recorded/{DELETE_v1_projects___PROJECT_REF___secrets => DELETE_v1_projects_replayfixtureprojref_secrets}/2.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{DELETE_v1_projects___PROJECT_REF___secrets => DELETE_v1_projects_replayfixtureprojref_secrets}/default.request.json (84%) rename apps/cli-e2e/fixtures/recorded/{DELETE_v1_projects___PROJECT_REF___network_bans => DELETE_v1_projects_replayfixtureprojref_secrets}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{DELETE_v1_projects___PROJECT_REF___functions_hello_world => DELETE_v1_projects_replayfixtureprojref_vanity_subdomain}/default.request.json (77%) rename apps/cli-e2e/fixtures/recorded/{DELETE_v1_projects___PROJECT_REF___vanity_subdomain => DELETE_v1_projects_replayfixtureprojref_vanity_subdomain}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF__ => GET_v1_branches_replayfixtureprojref}/default.request.json (82%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_branches___PROJECT_REF__ => GET_v1_branches_replayfixtureprojref}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_organizations___PROJECT_REF___entitlements => GET_v1_organizations_replayfixtureprojref_entitlements}/2.request.json (77%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_organizations___PROJECT_REF___entitlements => GET_v1_organizations_replayfixtureprojref_entitlements}/2.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___advisors_security => GET_v1_organizations_replayfixtureprojref_entitlements}/3.request.json (77%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_organizations___PROJECT_REF___entitlements => GET_v1_organizations_replayfixtureprojref_entitlements}/3.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_organizations___PROJECT_REF___entitlements => GET_v1_organizations_replayfixtureprojref_entitlements}/4.request.json (77%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_organizations___PROJECT_REF___entitlements => GET_v1_organizations_replayfixtureprojref_entitlements}/4.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_database_pooler => GET_v1_organizations_replayfixtureprojref_entitlements}/default.request.json (77%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_organizations___PROJECT_REF___entitlements => GET_v1_organizations_replayfixtureprojref_entitlements}/default.response.json (100%) delete mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/default.request.json delete mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches_my_branch/default.request.json delete mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/2.request.json delete mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/3.request.json delete mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/default.request.json delete mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/default.request.json delete mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/default.request.json delete mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___database_backups/default.request.json delete mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/default.request.json delete mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/2.request.json delete mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/3.request.json delete mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/default.request.json delete mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___pgsodium/default.request.json delete mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/default.request.json delete mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/2.request.json delete mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/default.request.json delete mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/2.request.json delete mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/default.request.json delete mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___vanity_subdomain/default.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF__ => GET_v1_projects_replayfixtureprojref}/2.request.json (82%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF__ => GET_v1_projects_replayfixtureprojref}/2.response.json (68%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF__ => GET_v1_projects_replayfixtureprojref}/3.request.json (82%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF__ => GET_v1_projects_replayfixtureprojref}/3.response.json (68%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF__ => GET_v1_projects_replayfixtureprojref}/4.request.json (82%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF__ => GET_v1_projects_replayfixtureprojref}/4.response.json (68%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_branches___PROJECT_REF__ => GET_v1_projects_replayfixtureprojref}/default.request.json (82%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF__ => GET_v1_projects_replayfixtureprojref}/default.response.json (68%) create mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/2.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___advisors_performance => GET_v1_projects_replayfixtureprojref_advisors_performance}/2.response.json (100%) create mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/default.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___advisors_performance => GET_v1_projects_replayfixtureprojref_advisors_performance}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___advisors_security => GET_v1_projects_replayfixtureprojref_advisors_security}/2.request.json (77%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___advisors_security => GET_v1_projects_replayfixtureprojref_advisors_security}/2.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_organizations___PROJECT_REF___entitlements => GET_v1_projects_replayfixtureprojref_advisors_security}/3.request.json (77%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___advisors_security => GET_v1_projects_replayfixtureprojref_advisors_security}/3.response.json (100%) create mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/default.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___advisors_security => GET_v1_projects_replayfixtureprojref_advisors_security}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___branches => GET_v1_projects_replayfixtureprojref_api_keys}/2.request.json (79%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___api_keys => GET_v1_projects_replayfixtureprojref_api_keys}/2.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___functions => GET_v1_projects_replayfixtureprojref_api_keys}/3.request.json (79%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___api_keys => GET_v1_projects_replayfixtureprojref_api_keys}/3.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___api_keys => GET_v1_projects_replayfixtureprojref_api_keys}/default.request.json (81%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___api_keys => GET_v1_projects_replayfixtureprojref_api_keys}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth => GET_v1_projects_replayfixtureprojref_billing_addons}/2.request.json (78%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___billing_addons => GET_v1_projects_replayfixtureprojref_billing_addons}/2.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___billing_addons => GET_v1_projects_replayfixtureprojref_billing_addons}/default.request.json (78%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___billing_addons => GET_v1_projects_replayfixtureprojref_billing_addons}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___functions => GET_v1_projects_replayfixtureprojref_branches}/2.request.json (79%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___secrets => GET_v1_projects_replayfixtureprojref_branches}/2.response.json (100%) create mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/default.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___secrets => GET_v1_projects_replayfixtureprojref_branches}/default.response.json (100%) create mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches_my_branch/default.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___branches_my_branch => GET_v1_projects_replayfixtureprojref_branches_my_branch}/default.response.json (92%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_storage => GET_v1_projects_replayfixtureprojref_config_auth}/2.request.json (79%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth => GET_v1_projects_replayfixtureprojref_config_auth}/2.response.json (99%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___postgrest => GET_v1_projects_replayfixtureprojref_config_auth}/3.request.json (79%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth => GET_v1_projects_replayfixtureprojref_config_auth}/3.response.json (99%) create mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/default.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth => GET_v1_projects_replayfixtureprojref_config_auth}/default.response.json (99%) create mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/2.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth_sso_providers => GET_v1_projects_replayfixtureprojref_config_auth_sso_providers}/2.response.json (100%) create mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/3.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth_sso_providers => GET_v1_projects_replayfixtureprojref_config_auth_sso_providers}/3.response.json (100%) create mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth_sso_providers => GET_v1_projects_replayfixtureprojref_config_auth_sso_providers}/default.response.json (100%) create mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/2.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__ => GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__}/2.response.json (100%) create mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/3.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__ => GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__}/3.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__ => GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__}/4.request.json (72%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__ => GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__}/4.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__ => GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__}/5.request.json (72%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__ => GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__}/5.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__ => GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__}/6.request.json (72%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__ => GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__}/6.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__ => GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__}/7.request.json (72%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__ => GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__}/7.response.json (100%) create mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/default.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__ => GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__}/default.response.json (100%) create mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_pooler/default.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_database_pooler => GET_v1_projects_replayfixtureprojref_config_database_pooler}/default.response.json (58%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_database_postgres => GET_v1_projects_replayfixtureprojref_config_database_postgres}/2.request.json (75%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_database_postgres => GET_v1_projects_replayfixtureprojref_config_database_postgres}/2.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_database_postgres => GET_v1_projects_replayfixtureprojref_config_database_postgres}/3.request.json (75%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_database_postgres => GET_v1_projects_replayfixtureprojref_config_database_postgres}/3.response.json (100%) create mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/default.request.json rename apps/cli-e2e/fixtures/recorded/{PUT_v1_projects___PROJECT_REF___config_database_postgres => GET_v1_projects_replayfixtureprojref_config_database_postgres}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___postgrest => GET_v1_projects_replayfixtureprojref_config_storage}/2.request.json (78%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_storage => GET_v1_projects_replayfixtureprojref_config_storage}/2.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___ssl_enforcement => GET_v1_projects_replayfixtureprojref_config_storage}/3.request.json (78%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_storage => GET_v1_projects_replayfixtureprojref_config_storage}/3.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_organizations___PROJECT_REF___entitlements => GET_v1_projects_replayfixtureprojref_config_storage}/default.request.json (77%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_storage => GET_v1_projects_replayfixtureprojref_config_storage}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___custom_hostname => GET_v1_projects_replayfixtureprojref_custom_hostname}/default.request.json (78%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___custom_hostname => GET_v1_projects_replayfixtureprojref_custom_hostname}/default.response.json (91%) create mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_database_backups/default.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___database_backups => GET_v1_projects_replayfixtureprojref_database_backups}/default.response.json (100%) create mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/2.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___functions => GET_v1_projects_replayfixtureprojref_functions}/2.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___secrets => GET_v1_projects_replayfixtureprojref_functions}/3.request.json (79%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___functions => GET_v1_projects_replayfixtureprojref_functions}/3.response.json (100%) create mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/default.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___functions => GET_v1_projects_replayfixtureprojref_functions}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth_sso_providers => GET_v1_projects_replayfixtureprojref_network_restrictions}/2.request.json (77%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___network_restrictions => GET_v1_projects_replayfixtureprojref_network_restrictions}/2.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth_sso_providers => GET_v1_projects_replayfixtureprojref_network_restrictions}/3.request.json (77%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___network_restrictions => GET_v1_projects_replayfixtureprojref_network_restrictions}/3.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___ssl_enforcement => GET_v1_projects_replayfixtureprojref_network_restrictions}/4.request.json (76%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___network_restrictions => GET_v1_projects_replayfixtureprojref_network_restrictions}/4.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___network_restrictions => GET_v1_projects_replayfixtureprojref_network_restrictions}/5.request.json (76%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___network_restrictions => GET_v1_projects_replayfixtureprojref_network_restrictions}/5.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth_sso_providers => GET_v1_projects_replayfixtureprojref_network_restrictions}/default.request.json (77%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___network_restrictions => GET_v1_projects_replayfixtureprojref_network_restrictions}/default.response.json (100%) create mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_pgsodium/default.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___pgsodium => GET_v1_projects_replayfixtureprojref_pgsodium}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___billing_addons => GET_v1_projects_replayfixtureprojref_postgrest}/2.request.json (79%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___postgrest => GET_v1_projects_replayfixtureprojref_postgrest}/2.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_storage => GET_v1_projects_replayfixtureprojref_postgrest}/3.request.json (79%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___postgrest => GET_v1_projects_replayfixtureprojref_postgrest}/3.response.json (100%) create mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/default.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___postgrest => GET_v1_projects_replayfixtureprojref_postgrest}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___api_keys => GET_v1_projects_replayfixtureprojref_secrets}/2.request.json (79%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___branches => GET_v1_projects_replayfixtureprojref_secrets}/2.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___api_keys => GET_v1_projects_replayfixtureprojref_secrets}/3.request.json (79%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___secrets => GET_v1_projects_replayfixtureprojref_secrets}/3.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth => GET_v1_projects_replayfixtureprojref_secrets}/default.request.json (79%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___branches => GET_v1_projects_replayfixtureprojref_secrets}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___advisors_performance => GET_v1_projects_replayfixtureprojref_ssl_enforcement}/2.request.json (77%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___ssl_enforcement => GET_v1_projects_replayfixtureprojref_ssl_enforcement}/2.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_auth => GET_v1_projects_replayfixtureprojref_ssl_enforcement}/3.request.json (78%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___ssl_enforcement => GET_v1_projects_replayfixtureprojref_ssl_enforcement}/3.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___network_restrictions => GET_v1_projects_replayfixtureprojref_ssl_enforcement}/4.request.json (78%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___ssl_enforcement => GET_v1_projects_replayfixtureprojref_ssl_enforcement}/4.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___advisors_performance => GET_v1_projects_replayfixtureprojref_ssl_enforcement}/default.request.json (77%) rename apps/cli-e2e/fixtures/recorded/{PUT_v1_projects___PROJECT_REF___ssl_enforcement => GET_v1_projects_replayfixtureprojref_ssl_enforcement}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___types_typescript => GET_v1_projects_replayfixtureprojref_types_typescript}/default.request.json (79%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___types_typescript => GET_v1_projects_replayfixtureprojref_types_typescript}/default.response.json (100%) create mode 100644 apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_vanity_subdomain/default.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___vanity_subdomain => GET_v1_projects_replayfixtureprojref_vanity_subdomain}/default.response.json (100%) delete mode 100644 apps/cli-e2e/fixtures/recorded/GET_v2_projects___PROJECT_REF___config/default.request.json rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___branches => GET_v2_projects_replayfixtureprojref_config}/default.request.json (80%) rename apps/cli-e2e/fixtures/recorded/{GET_v2_projects___PROJECT_REF___config => GET_v2_projects_replayfixtureprojref_config}/default.response.json (99%) rename apps/cli-e2e/fixtures/recorded/{PATCH_v1_projects___PROJECT_REF___config_auth => PATCH_v1_projects_replayfixtureprojref_config_auth}/default.request.json (86%) rename apps/cli-e2e/fixtures/recorded/{PATCH_v1_projects___PROJECT_REF___config_auth => PATCH_v1_projects_replayfixtureprojref_config_auth}/default.response.json (99%) rename apps/cli-e2e/fixtures/recorded/{PATCH_v1_projects___PROJECT_REF___network_restrictions => PATCH_v1_projects_replayfixtureprojref_network_restrictions}/default.request.json (84%) rename apps/cli-e2e/fixtures/recorded/{PATCH_v1_projects___PROJECT_REF___network_restrictions => PATCH_v1_projects_replayfixtureprojref_network_restrictions}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___branches => POST_v1_projects_replayfixtureprojref_branches}/default.request.json (79%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___branches => POST_v1_projects_replayfixtureprojref_branches}/default.response.json (92%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___config_auth_sso_providers => POST_v1_projects_replayfixtureprojref_config_auth_sso_providers}/default.request.json (91%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___config_auth_sso_providers => POST_v1_projects_replayfixtureprojref_config_auth_sso_providers}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___custom_hostname_activate => POST_v1_projects_replayfixtureprojref_custom_hostname_activate}/default.request.json (77%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___custom_hostname_activate => POST_v1_projects_replayfixtureprojref_custom_hostname_activate}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___custom_hostname_reverify => POST_v1_projects_replayfixtureprojref_custom_hostname_reverify}/default.request.json (77%) rename apps/cli-e2e/fixtures/recorded/{DELETE_v1_projects___PROJECT_REF___custom_hostname => POST_v1_projects_replayfixtureprojref_custom_hostname_reverify}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___database_backups_restore_pitr => POST_v1_projects_replayfixtureprojref_database_backups_restore_pitr}/default.request.json (81%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___database_backups_restore_pitr => POST_v1_projects_replayfixtureprojref_database_backups_restore_pitr}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___database_query => POST_v1_projects_replayfixtureprojref_database_query}/2.request.json (83%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___database_query => POST_v1_projects_replayfixtureprojref_database_query}/2.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___database_query => POST_v1_projects_replayfixtureprojref_database_query}/default.request.json (83%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___database_query => POST_v1_projects_replayfixtureprojref_database_query}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___database_webhooks_enable => POST_v1_projects_replayfixtureprojref_database_webhooks_enable}/2.request.json (77%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___database_webhooks_enable => POST_v1_projects_replayfixtureprojref_database_webhooks_enable}/2.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___database_webhooks_enable => POST_v1_projects_replayfixtureprojref_database_webhooks_enable}/default.request.json (77%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___secrets => POST_v1_projects_replayfixtureprojref_database_webhooks_enable}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___network_bans_retrieve => POST_v1_projects_replayfixtureprojref_network_bans_retrieve}/2.request.json (78%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___network_bans_retrieve => POST_v1_projects_replayfixtureprojref_network_bans_retrieve}/2.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___network_bans_retrieve => POST_v1_projects_replayfixtureprojref_network_bans_retrieve}/3.request.json (78%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___network_bans_retrieve => POST_v1_projects_replayfixtureprojref_network_bans_retrieve}/3.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___network_bans_retrieve => POST_v1_projects_replayfixtureprojref_network_bans_retrieve}/default.request.json (78%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___network_bans_retrieve => POST_v1_projects_replayfixtureprojref_network_bans_retrieve}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___network_restrictions_apply => POST_v1_projects_replayfixtureprojref_network_restrictions_apply}/2.request.json (82%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___network_restrictions_apply => POST_v1_projects_replayfixtureprojref_network_restrictions_apply}/2.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___network_restrictions_apply => POST_v1_projects_replayfixtureprojref_network_restrictions_apply}/default.request.json (82%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___network_restrictions_apply => POST_v1_projects_replayfixtureprojref_network_restrictions_apply}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___secrets => POST_v1_projects_replayfixtureprojref_secrets}/2.request.json (87%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___secrets => POST_v1_projects_replayfixtureprojref_secrets}/2.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___secrets => POST_v1_projects_replayfixtureprojref_secrets}/3.request.json (86%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___secrets => POST_v1_projects_replayfixtureprojref_secrets}/3.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___secrets => POST_v1_projects_replayfixtureprojref_secrets}/4.request.json (87%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___secrets => POST_v1_projects_replayfixtureprojref_secrets}/4.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___secrets => POST_v1_projects_replayfixtureprojref_secrets}/default.request.json (86%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___database_webhooks_enable => POST_v1_projects_replayfixtureprojref_secrets}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___vanity_subdomain_activate => POST_v1_projects_replayfixtureprojref_vanity_subdomain_activate}/default.request.json (81%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___vanity_subdomain_activate => POST_v1_projects_replayfixtureprojref_vanity_subdomain_activate}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___vanity_subdomain_check_availability => POST_v1_projects_replayfixtureprojref_vanity_subdomain_check_availability}/default.request.json (79%) rename apps/cli-e2e/fixtures/recorded/{POST_v1_projects___PROJECT_REF___vanity_subdomain_check_availability => POST_v1_projects_replayfixtureprojref_vanity_subdomain_check_availability}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{PUT_v1_projects___PROJECT_REF___config_database_postgres => PUT_v1_projects_replayfixtureprojref_config_database_postgres}/default.request.json (85%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___config_database_postgres => PUT_v1_projects_replayfixtureprojref_config_database_postgres}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{PUT_v1_projects___PROJECT_REF___pgsodium => PUT_v1_projects_replayfixtureprojref_pgsodium}/default.request.json (84%) rename apps/cli-e2e/fixtures/recorded/{PUT_v1_projects___PROJECT_REF___pgsodium => PUT_v1_projects_replayfixtureprojref_pgsodium}/default.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{PUT_v1_projects___PROJECT_REF___ssl_enforcement => PUT_v1_projects_replayfixtureprojref_ssl_enforcement}/2.request.json (84%) rename apps/cli-e2e/fixtures/recorded/{PUT_v1_projects___PROJECT_REF___ssl_enforcement => PUT_v1_projects_replayfixtureprojref_ssl_enforcement}/2.response.json (100%) rename apps/cli-e2e/fixtures/recorded/{PUT_v1_projects___PROJECT_REF___ssl_enforcement => PUT_v1_projects_replayfixtureprojref_ssl_enforcement}/default.request.json (84%) rename apps/cli-e2e/fixtures/recorded/{GET_v1_projects___PROJECT_REF___ssl_enforcement => PUT_v1_projects_replayfixtureprojref_ssl_enforcement}/default.response.json (100%) delete mode 100644 apps/cli/src/command-internal/raw-http.ts create mode 100644 apps/cli/tests/lint/no-raw-management-api-url.unit.test.ts create mode 100644 tools/oxlint/supabase-plugin.js diff --git a/.oxlintrc.json b/.oxlintrc.json index 228e77cd93..cb9deecdd7 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -35,6 +35,16 @@ "typescript/no-base-to-string": "off" } }, + { + // Management API calls go through the typed `@supabase/api` client so they emit the + // `api.operation` span; tests build stub URLs freely. + "files": ["apps/cli/src/**"], + "excludeFiles": ["**/*.test.ts"], + "jsPlugins": ["./tools/oxlint/supabase-plugin.js"], + "rules": { + "supabase/no-raw-management-api-url": "error" + } + }, { // `.github/scripts` is the only `bun:test` consumer; `@types/bun`'s shared `Matchers` // types `.rejects.toThrow(...)` as returning `void`, even though it must be awaited at diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4c6d586af5..20b02d8a8a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -240,7 +240,7 @@ Recording proxies CLI traffic to the Supabase staging API. Provide a staging acc SUPABASE_ACCESS_TOKEN= SUPABASE_TEST_PROJECT_REF= SUPABASE_STAGING_URL= pnpm run record ``` -Review the generated files in `apps/cli-e2e/fixtures/recorded/` before committing — verify that no real tokens, UUIDs, or project refs appear (they should be replaced with `__ACCESS_TOKEN__`, `__UUID__`, `__PROJECT_REF__` placeholders). +Review the generated files in `apps/cli-e2e/fixtures/recorded/` before committing — verify that no real tokens, UUIDs, or project refs appear (they should be replaced with `__ACCESS_TOKEN__` and `__UUID__` placeholders and the fixed `replayfixtureprojref` ref). ### Verifying fixtures diff --git a/apps/cli-e2e/AGENTS.md b/apps/cli-e2e/AGENTS.md index 0e6c6ad342..41f46855f0 100644 --- a/apps/cli-e2e/AGENTS.md +++ b/apps/cli-e2e/AGENTS.md @@ -119,23 +119,23 @@ The fixture for this test is hand-authored in `fixtures/scenarios//interac ## Writing assertions that work in both modes -In **replay mode**, fixture data contains the literal placeholder string `__PROJECT_REF__` — the CLI renders it as-is. In **record mode**, the CLI renders the real 20-char ref. +In **replay mode**, fixture data contains one fixed, schema-valid 20-char ref (`REPLAY_PROJECT_REF` in `src/server/placeholder.ts`) in place of every project ref and org slug — the CLI renders it as-is. In **record mode**, the CLI renders the real 20-char ref. -Never assert on the literal placeholder in a way that breaks in record mode: +Never assert on the literal replay ref in a way that breaks in record mode: ```typescript // WRONG — fails in record mode -expect(result.stdout).toContain("__PROJECT_REF__"); +expect(result.stdout).toContain(REPLAY_PROJECT_REF); -// CORRECT — matches real ref in record mode, placeholder in replay -expect(result.stdout).toMatch(/[a-z]{20}|__PROJECT_REF__/); +// CORRECT — matches the real ref in record mode and the replay ref in replay +expect(result.stdout).toMatch(/[a-z]{20}/); ``` For JSON output assertions, use `toMatchObject` with `expect.any(String)` for ref fields so they pass regardless of whether the value is a real ref or the placeholder. ## Global setup (`tests/setup.ts`) -In **replay mode**: `projectRef` and `orgId` are set to default values (`"aaaaaaaaaaaaaaaaaaaa"` and the same). No API calls are made. +In **replay mode**: `projectRef` and `orgId` are set to `REPLAY_PROJECT_REF`. No API calls are made. In **record mode**: global setup resolves the org, deletes any orphaned test projects by name (from previous failed runs), creates a fresh `cli-e2e-test` project, and provides its ref via Vitest's `inject()` mechanism. This project is deleted in the global teardown. diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_storage_v1_object_cli_e2e_bucket/2.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_storage_v1_object_cli_e2e_bucket/2.response.json index 5961a093df..a841c9cf4f 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_storage_v1_object_cli_e2e_bucket/2.response.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_storage_v1_object_cli_e2e_bucket/2.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_storage_v1_object_cli_e2e_bucket/default.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_storage_v1_object_cli_e2e_bucket/default.response.json index 3fc284cab3..3f5b898b23 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_storage_v1_object_cli_e2e_bucket/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_storage_v1_object_cli_e2e_bucket/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_branches___PROJECT_REF__/default.request.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_branches_replayfixtureprojref/default.request.json similarity index 82% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_branches___PROJECT_REF__/default.request.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_branches_replayfixtureprojref/default.request.json index 141533f161..3efce9a4af 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_branches___PROJECT_REF__/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_branches_replayfixtureprojref/default.request.json @@ -1,6 +1,6 @@ { "method": "DELETE", - "path": "/v1/branches/__PROJECT_REF__", + "path": "/v1/branches/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___functions_hello_world/default.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_branches_replayfixtureprojref/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___functions_hello_world/default.response.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_branches_replayfixtureprojref/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/2.request.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/2.request.json similarity index 82% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/2.request.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/2.request.json index 1471da09df..3155ebf1db 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/2.request.json @@ -1,6 +1,6 @@ { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/2.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/2.response.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/default.request.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/default.request.json similarity index 82% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/default.request.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/default.request.json index 1471da09df..3155ebf1db 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/default.request.json @@ -1,6 +1,6 @@ { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/default.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/default.response.json similarity index 80% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/default.response.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/default.response.json index f16972d2f7..3929209ea4 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/default.response.json @@ -10,7 +10,7 @@ "body": { "id": 711274, "name": "cli-e2e-test", - "ref": "__PROJECT_REF__", - "slug": "__PROJECT_REF__" + "ref": "replayfixtureprojref", + "slug": "replayfixtureprojref" } } diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___vanity_subdomain/default.request.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_custom_hostname/default.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___vanity_subdomain/default.request.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_custom_hostname/default.request.json index b88a0bf0a9..b13ee1ff5c 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___vanity_subdomain/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_custom_hostname/default.request.json @@ -1,6 +1,6 @@ { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain", + "path": "/v1/projects/replayfixtureprojref/custom-hostname", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___custom_hostname_reverify/default.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_custom_hostname/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___custom_hostname_reverify/default.response.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_custom_hostname/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___custom_hostname/default.request.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_functions_hello_world/default.request.json similarity index 76% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___custom_hostname/default.request.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_functions_hello_world/default.request.json index f1701f2109..2a26e11bec 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___custom_hostname/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_functions_hello_world/default.request.json @@ -1,6 +1,6 @@ { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname", + "path": "/v1/projects/replayfixtureprojref/functions/hello-world", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_branches___PROJECT_REF__/default.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_functions_hello_world/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_branches___PROJECT_REF__/default.response.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_functions_hello_world/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___network_bans/2.request.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_network_bans/2.request.json similarity index 85% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___network_bans/2.request.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_network_bans/2.request.json index c8e02c2037..dbcd6660d4 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___network_bans/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_network_bans/2.request.json @@ -1,6 +1,6 @@ { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/network-bans", + "path": "/v1/projects/replayfixtureprojref/network-bans", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___network_bans/2.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_network_bans/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___network_bans/2.response.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_network_bans/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___network_bans/default.request.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_network_bans/default.request.json similarity index 85% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___network_bans/default.request.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_network_bans/default.request.json index af07df0517..73f2a787e1 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___network_bans/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_network_bans/default.request.json @@ -1,6 +1,6 @@ { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/network-bans", + "path": "/v1/projects/replayfixtureprojref/network-bans", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___secrets/default.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_network_bans/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___secrets/default.response.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_network_bans/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___secrets/2.request.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_secrets/2.request.json similarity index 84% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___secrets/2.request.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_secrets/2.request.json index b6afc7d56e..a4de2f8fc2 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___secrets/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_secrets/2.request.json @@ -1,6 +1,6 @@ { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___secrets/2.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_secrets/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___secrets/2.response.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_secrets/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___secrets/default.request.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_secrets/default.request.json similarity index 84% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___secrets/default.request.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_secrets/default.request.json index 09f50d2661..7296dbc6f6 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___secrets/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_secrets/default.request.json @@ -1,6 +1,6 @@ { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___network_bans/default.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_secrets/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___network_bans/default.response.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_secrets/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___functions_hello_world/default.request.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_vanity_subdomain/default.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___functions_hello_world/default.request.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_vanity_subdomain/default.request.json index 4b5e2fa0dc..b289817eed 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___functions_hello_world/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_vanity_subdomain/default.request.json @@ -1,6 +1,6 @@ { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/functions/hello-world", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___vanity_subdomain/default.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_vanity_subdomain/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___vanity_subdomain/default.response.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_vanity_subdomain/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_storage_v1_bucket/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_storage_v1_bucket/2.response.json index d89f7e1a63..122a8e415e 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_storage_v1_bucket/2.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_storage_v1_bucket/2.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/recorded/GET_storage_v1_bucket/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_storage_v1_bucket/default.response.json index d89f7e1a63..122a8e415e 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_storage_v1_bucket/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_storage_v1_bucket/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/recorded/GET_storage_v1_object_cli_e2e_bucket_hello_txt/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_storage_v1_object_cli_e2e_bucket_hello_txt/default.response.json index 69a8834ecd..dcfc43224a 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_storage_v1_object_cli_e2e_bucket_hello_txt/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_storage_v1_object_cli_e2e_bucket_hello_txt/default.response.json @@ -8,7 +8,7 @@ "last-modified": "Mon, 27 Apr 2026 10:30:57 GMT", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-robots-tag": "none", "x-smart-cdn": "true" diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_branches_replayfixtureprojref/default.request.json similarity index 82% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_branches_replayfixtureprojref/default.request.json index 77c0083556..f9c59b0d8e 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_branches_replayfixtureprojref/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/branches/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_branches___PROJECT_REF__/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_branches_replayfixtureprojref/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_branches___PROJECT_REF__/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_branches_replayfixtureprojref/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/2.response.json index 3bfe42b35f..75ad025153 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/2.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/2.response.json @@ -9,13 +9,13 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Org" }, { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "CLIPort-TEST" } ] diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/3.response.json index eefab335ea..bb6a5bedfb 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/3.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/3.response.json @@ -9,13 +9,13 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Org" }, { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "CLIPort-TEST" } ] diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/4.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/4.response.json index 63df5e6545..17adb27258 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/4.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/4.response.json @@ -9,13 +9,13 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Org" }, { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "CLIPort-TEST" } ] diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/default.response.json index 3bfe42b35f..75ad025153 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/default.response.json @@ -9,13 +9,13 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Org" }, { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "CLIPort-TEST" } ] diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/2.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/2.request.json index c60f7965d4..e79186c1ab 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/organizations/__PROJECT_REF__/entitlements", + "path": "/v1/organizations/replayfixtureprojref/entitlements", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/3.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/3.request.json index 9c54657855..e79186c1ab 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/advisors/security", + "path": "/v1/organizations/replayfixtureprojref/entitlements", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/4.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/4.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/4.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/4.request.json index c60f7965d4..e79186c1ab 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/4.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/4.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/organizations/__PROJECT_REF__/entitlements", + "path": "/v1/organizations/replayfixtureprojref/entitlements", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/4.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/4.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/4.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/4.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_pooler/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/default.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_pooler/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/default.request.json index 3620f448c6..e79186c1ab 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_pooler/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/pooler", + "path": "/v1/organizations/replayfixtureprojref/entitlements", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects/2.response.json index ea0c2328ee..b79ece0a34 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects/2.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects/2.response.json @@ -9,15 +9,15 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Project", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.084", "postgres_engine": "17", "release_channel": "ga" @@ -25,15 +25,15 @@ "created_at": "2000-01-01T00:00:00Z" }, { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects/3.response.json index 7a3b6a1879..7d1abfe29b 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects/3.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects/3.response.json @@ -9,15 +9,15 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Project", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.084", "postgres_engine": "17", "release_channel": "ga" @@ -25,15 +25,15 @@ "created_at": "2000-01-01T00:00:00Z" }, { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects/4.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects/4.response.json index 911ad4afd3..0bfd2e70f2 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects/4.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects/4.response.json @@ -9,15 +9,15 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Project", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.084", "postgres_engine": "17", "release_channel": "ga" @@ -25,15 +25,15 @@ "created_at": "2000-01-01T00:00:00Z" }, { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects/5.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects/5.response.json index 86c1c85fb5..61707be8a6 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects/5.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects/5.response.json @@ -9,15 +9,15 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Project", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.084", "postgres_engine": "17", "release_channel": "ga" @@ -25,15 +25,15 @@ "created_at": "2000-01-01T00:00:00Z" }, { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" @@ -41,15 +41,15 @@ "created_at": "2000-01-01T00:00:00Z" }, { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "my-project", "region": "us-east-1", "status": "COMING_UP", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects/default.response.json index ea0c2328ee..b79ece0a34 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects/default.response.json @@ -9,15 +9,15 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Project", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.084", "postgres_engine": "17", "release_channel": "ga" @@ -25,15 +25,15 @@ "created_at": "2000-01-01T00:00:00Z" }, { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/default.request.json deleted file mode 100644 index 9c54657855..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/advisors/security", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches_my_branch/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches_my_branch/default.request.json deleted file mode 100644 index f38ef4349b..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches_my_branch/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches/my-branch", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/2.request.json deleted file mode 100644 index 63189a3535..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/2.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/3.request.json deleted file mode 100644 index 63189a3535..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/3.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/default.request.json deleted file mode 100644 index 63189a3535..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/default.request.json deleted file mode 100644 index 645fc3d4f4..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/default.request.json deleted file mode 100644 index e51a9af732..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/storage", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___database_backups/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___database_backups/default.request.json deleted file mode 100644 index ae8adec0cc..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___database_backups/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/database/backups", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/default.request.json deleted file mode 100644 index e3a71b1a58..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/functions", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/2.request.json deleted file mode 100644 index 7f7b19d1bf..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/2.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/3.request.json deleted file mode 100644 index 7f7b19d1bf..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/3.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/default.request.json deleted file mode 100644 index 7f7b19d1bf..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___pgsodium/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___pgsodium/default.request.json deleted file mode 100644 index 270dcdd57b..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___pgsodium/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/pgsodium", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/default.request.json deleted file mode 100644 index 7f159f3ec9..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/postgrest", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/2.request.json deleted file mode 100644 index f37a9dc97c..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/2.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/secrets", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/default.request.json deleted file mode 100644 index f37a9dc97c..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/secrets", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/2.request.json deleted file mode 100644 index a5c051c329..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/2.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/ssl-enforcement", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/default.request.json deleted file mode 100644 index a5c051c329..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/ssl-enforcement", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___vanity_subdomain/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___vanity_subdomain/default.request.json deleted file mode 100644 index 59ba9879d8..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___vanity_subdomain/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/2.request.json similarity index 82% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/2.request.json index 77c0083556..9c3ddd0b73 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/2.response.json similarity index 68% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/2.response.json index 50c697a48b..a21ea54e57 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/2.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/2.response.json @@ -8,15 +8,15 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/3.request.json similarity index 82% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/3.request.json index 77c0083556..9c3ddd0b73 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/3.response.json similarity index 68% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/3.response.json index 0df4659eab..1729b4c75f 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/3.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/3.response.json @@ -8,15 +8,15 @@ "x-ratelimit-reset": "58" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/4.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/4.request.json similarity index 82% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/4.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/4.request.json index 77c0083556..9c3ddd0b73 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/4.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/4.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/4.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/4.response.json similarity index 68% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/4.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/4.response.json index 75b639f755..a736fec22f 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/4.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/4.response.json @@ -8,15 +8,15 @@ "x-ratelimit-reset": "54" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_branches___PROJECT_REF__/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/default.request.json similarity index 82% rename from apps/cli-e2e/fixtures/recorded/GET_v1_branches___PROJECT_REF__/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/default.request.json index 91ec0cfd60..9c3ddd0b73 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_branches___PROJECT_REF__/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/branches/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/default.response.json similarity index 68% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/default.response.json index 837263accc..1415f96058 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/default.response.json @@ -8,15 +8,15 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/2.request.json new file mode 100644 index 0000000000..b93e07e013 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/2.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/advisors/performance", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_performance/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_performance/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/default.request.json new file mode 100644 index 0000000000..b93e07e013 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/advisors/performance", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_performance/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_performance/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/2.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/2.request.json index 9c54657855..436b3c7567 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/advisors/security", + "path": "/v1/projects/replayfixtureprojref/advisors/security", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/3.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/3.request.json index c60f7965d4..436b3c7567 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/organizations/__PROJECT_REF__/entitlements", + "path": "/v1/projects/replayfixtureprojref/advisors/security", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/default.request.json new file mode 100644 index 0000000000..436b3c7567 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/advisors/security", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/2.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/2.request.json index 90e1a616cb..6d1c89bb01 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches", + "path": "/v1/projects/replayfixtureprojref/api-keys", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/3.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/3.request.json index e3a71b1a58..6d1c89bb01 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/functions", + "path": "/v1/projects/replayfixtureprojref/api-keys", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/default.request.json similarity index 81% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/default.request.json index 6ae3fb5570..5af6c5c005 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/api-keys", + "path": "/v1/projects/replayfixtureprojref/api-keys", "query": { "reveal": "true" }, diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_billing_addons/2.request.json similarity index 78% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_billing_addons/2.request.json index 34474f2c64..f286c8407a 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_billing_addons/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth", + "path": "/v1/projects/replayfixtureprojref/billing/addons", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___billing_addons/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_billing_addons/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___billing_addons/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_billing_addons/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___billing_addons/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_billing_addons/default.request.json similarity index 78% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___billing_addons/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_billing_addons/default.request.json index ad666f2604..f286c8407a 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___billing_addons/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_billing_addons/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/billing/addons", + "path": "/v1/projects/replayfixtureprojref/billing/addons", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___billing_addons/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_billing_addons/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___billing_addons/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_billing_addons/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/2.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/2.request.json index e3a71b1a58..7de75b610d 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/functions", + "path": "/v1/projects/replayfixtureprojref/branches", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/default.request.json new file mode 100644 index 0000000000..7de75b610d --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/branches", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches_my_branch/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches_my_branch/default.request.json new file mode 100644 index 0000000000..daf9168dd0 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches_my_branch/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/branches/my-branch", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches_my_branch/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches_my_branch/default.response.json similarity index 92% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches_my_branch/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches_my_branch/default.response.json index 1c3c6655d5..7f16ebf2f2 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches_my_branch/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches_my_branch/default.response.json @@ -11,7 +11,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/2.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/2.request.json index a53b9601cf..8f1ae1d649 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/storage", + "path": "/v1/projects/replayfixtureprojref/config/auth", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/2.response.json similarity index 99% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/2.response.json index ade1f10f11..094d8a0403 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/2.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/2.response.json @@ -40,6 +40,7 @@ "hook_before_user_created_secrets": null, "nimbus_oauth_client_id": null, "nimbus_oauth_client_secret": null, + "nimbus_oauth_email_optional": null, "external_anonymous_users_enabled": false, "external_apple_additional_client_ids": null, "external_apple_client_id": null, diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/3.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/3.request.json index fb1539a87b..8f1ae1d649 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/postgrest", + "path": "/v1/projects/replayfixtureprojref/config/auth", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/3.response.json similarity index 99% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/3.response.json index 9ae9974882..287ee554dc 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/3.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/3.response.json @@ -40,6 +40,7 @@ "hook_before_user_created_secrets": null, "nimbus_oauth_client_id": null, "nimbus_oauth_client_secret": null, + "nimbus_oauth_email_optional": null, "external_anonymous_users_enabled": false, "external_apple_additional_client_ids": null, "external_apple_client_id": null, diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/default.request.json new file mode 100644 index 0000000000..de978f043d --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/config/auth", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/default.response.json similarity index 99% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/default.response.json index e4adf44716..49f7407b5b 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/default.response.json @@ -40,6 +40,7 @@ "hook_before_user_created_secrets": null, "nimbus_oauth_client_id": null, "nimbus_oauth_client_secret": null, + "nimbus_oauth_email_optional": null, "external_anonymous_users_enabled": false, "external_apple_additional_client_ids": null, "external_apple_client_id": null, diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/2.request.json new file mode 100644 index 0000000000..dc27bb7cb1 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/2.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/3.request.json new file mode 100644 index 0000000000..dc27bb7cb1 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/3.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.request.json new file mode 100644 index 0000000000..dc27bb7cb1 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/2.request.json new file mode 100644 index 0000000000..2b5e4cebb8 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/2.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/3.request.json new file mode 100644 index 0000000000..2b5e4cebb8 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/3.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/4.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/4.request.json similarity index 72% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/4.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/4.request.json index 63189a3535..2b5e4cebb8 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/4.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/4.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/4.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/4.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/4.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/4.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/5.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/5.request.json similarity index 72% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/5.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/5.request.json index 63189a3535..2b5e4cebb8 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/5.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/5.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/5.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/5.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/5.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/5.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/6.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/6.request.json similarity index 72% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/6.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/6.request.json index 63189a3535..2b5e4cebb8 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/6.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/6.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/6.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/6.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/6.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/6.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/7.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/7.request.json similarity index 72% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/7.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/7.request.json index 63189a3535..2b5e4cebb8 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/7.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/7.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/7.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/7.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/7.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/7.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/default.request.json new file mode 100644 index 0000000000..2b5e4cebb8 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_pooler/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_pooler/default.request.json new file mode 100644 index 0000000000..504cea6c4a --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_pooler/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/config/database/pooler", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_pooler/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_pooler/default.response.json similarity index 58% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_pooler/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_pooler/default.response.json index 2cc616268a..ab814c18a7 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_pooler/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_pooler/default.response.json @@ -9,18 +9,18 @@ }, "body": [ { - "identifier": "__PROJECT_REF__", + "identifier": "replayfixtureprojref", "database_type": "PRIMARY", - "db_user": "postgres.__PROJECT_REF__", + "db_user": "postgres.replayfixtureprojref", "db_host": "aws-0-us-east-1.pooler.supabase.green", "db_port": 6543, "db_name": "postgres", - "connection_string": "postgresql://postgres.__PROJECT_REF__:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.green:6543/postgres", + "connection_string": "postgresql://postgres.replayfixtureprojref:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.green:6543/postgres", "pool_mode": "transaction", "default_pool_size": null, "max_client_conn": null, "is_using_scram_auth": true, - "connectionString": "postgresql://postgres.__PROJECT_REF__:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.green:6543/postgres" + "connectionString": "postgresql://postgres.replayfixtureprojref:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.green:6543/postgres" } ] } diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/2.request.json similarity index 75% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/2.request.json index 5f28eff1d9..bcc36e7d26 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/3.request.json similarity index 75% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/3.request.json index 5f28eff1d9..bcc36e7d26 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/default.request.json new file mode 100644 index 0000000000..3bbe24a8a8 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___config_database_postgres/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___config_database_postgres/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/2.request.json similarity index 78% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/2.request.json index fb1539a87b..c3ae1d41bd 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/postgrest", + "path": "/v1/projects/replayfixtureprojref/config/storage", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/3.request.json similarity index 78% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/3.request.json index 28d7b8cf12..c3ae1d41bd 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/ssl-enforcement", + "path": "/v1/projects/replayfixtureprojref/config/storage", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/default.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/default.request.json index c60f7965d4..0eb54f142f 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/organizations/__PROJECT_REF__/entitlements", + "path": "/v1/projects/replayfixtureprojref/config/storage", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___custom_hostname/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_custom_hostname/default.request.json similarity index 78% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___custom_hostname/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_custom_hostname/default.request.json index 7b19d0d356..b334b91cdd 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___custom_hostname/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_custom_hostname/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname", + "path": "/v1/projects/replayfixtureprojref/custom-hostname", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___custom_hostname/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_custom_hostname/default.response.json similarity index 91% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___custom_hostname/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_custom_hostname/default.response.json index d8b34de963..796753b938 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___custom_hostname/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_custom_hostname/default.response.json @@ -13,7 +13,7 @@ "errors": [], "messages": [], "result": { - "custom_origin_server": "__PROJECT_REF__.supabase.red", + "custom_origin_server": "replayfixtureprojref.supabase.red", "hostname": "www.urgsimurksi.xyz", "id": "00000000-0000-0000-0000-000000000000", "ownership_verification": { diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_database_backups/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_database_backups/default.request.json new file mode 100644 index 0000000000..39d7a2a801 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_database_backups/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/database/backups", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___database_backups/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_database_backups/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___database_backups/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_database_backups/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/2.request.json new file mode 100644 index 0000000000..dfdd712a97 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/2.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/functions", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/3.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/3.request.json index f37a9dc97c..dfdd712a97 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/functions", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/default.request.json new file mode 100644 index 0000000000..dfdd712a97 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/functions", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/2.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/2.request.json index 0377f2b154..01c1a271a3 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers", + "path": "/v1/projects/replayfixtureprojref/network-restrictions", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/3.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/3.request.json index 0377f2b154..01c1a271a3 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers", + "path": "/v1/projects/replayfixtureprojref/network-restrictions", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/4.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/4.request.json similarity index 76% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/4.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/4.request.json index 28d7b8cf12..0a63baf691 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/4.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/4.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/ssl-enforcement", + "path": "/v1/projects/replayfixtureprojref/network-restrictions", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/4.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/4.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/4.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/4.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/5.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/5.request.json similarity index 76% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/5.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/5.request.json index 1967a5e75d..0a63baf691 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/5.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/5.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions", + "path": "/v1/projects/replayfixtureprojref/network-restrictions", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/5.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/5.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/5.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/5.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/default.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/default.request.json index 0377f2b154..01c1a271a3 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers", + "path": "/v1/projects/replayfixtureprojref/network-restrictions", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_pgsodium/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_pgsodium/default.request.json new file mode 100644 index 0000000000..f888005edf --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_pgsodium/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/pgsodium", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___pgsodium/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_pgsodium/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___pgsodium/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_pgsodium/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___billing_addons/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/2.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___billing_addons/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/2.request.json index ad666f2604..9e8b4fa65c 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___billing_addons/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/billing/addons", + "path": "/v1/projects/replayfixtureprojref/postgrest", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/3.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/3.request.json index a53b9601cf..9e8b4fa65c 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/storage", + "path": "/v1/projects/replayfixtureprojref/postgrest", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/default.request.json new file mode 100644 index 0000000000..763abd0287 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/postgrest", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/2.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/2.request.json index d0df1be1a8..11889a39ed 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/api-keys", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/3.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/3.request.json index d0df1be1a8..11889a39ed 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/api-keys", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/default.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/default.request.json index ffc9c92d30..11889a39ed 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_performance/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/2.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_performance/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/2.request.json index 6e7314c9b8..36609c7cd6 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_performance/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/advisors/performance", + "path": "/v1/projects/replayfixtureprojref/ssl-enforcement", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/3.request.json similarity index 78% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/3.request.json index 34474f2c64..aa3fcd1571 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth", + "path": "/v1/projects/replayfixtureprojref/ssl-enforcement", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/4.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/4.request.json similarity index 78% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/4.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/4.request.json index 1967a5e75d..aa3fcd1571 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/4.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/4.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions", + "path": "/v1/projects/replayfixtureprojref/ssl-enforcement", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/4.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/4.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/4.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/4.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_performance/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/default.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_performance/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/default.request.json index 6e7314c9b8..36609c7cd6 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_performance/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/advisors/performance", + "path": "/v1/projects/replayfixtureprojref/ssl-enforcement", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___ssl_enforcement/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___ssl_enforcement/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___types_typescript/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_types_typescript/default.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___types_typescript/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_types_typescript/default.request.json index d6130d2ae4..3734320ac5 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___types_typescript/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_types_typescript/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/types/typescript", + "path": "/v1/projects/replayfixtureprojref/types/typescript", "query": { "included_schemas": "public" }, diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___types_typescript/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_types_typescript/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___types_typescript/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_types_typescript/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_vanity_subdomain/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_vanity_subdomain/default.request.json new file mode 100644 index 0000000000..2cc935d284 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_vanity_subdomain/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___vanity_subdomain/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_vanity_subdomain/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___vanity_subdomain/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_vanity_subdomain/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_snippets/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_snippets/default.request.json index 5c0416736f..94a7c4a640 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_snippets/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_snippets/default.request.json @@ -2,7 +2,7 @@ "method": "GET", "path": "/v1/snippets", "query": { - "project_ref": "__PROJECT_REF__" + "project_ref": "replayfixtureprojref" }, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v2_projects___PROJECT_REF___config/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v2_projects___PROJECT_REF___config/default.request.json deleted file mode 100644 index 92189eef6e..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v2_projects___PROJECT_REF___config/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v2/projects/__PROJECT_REF__/config", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v2_projects_replayfixtureprojref_config/default.request.json similarity index 80% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v2_projects_replayfixtureprojref_config/default.request.json index 90e1a616cb..1fd2c6df3f 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v2_projects_replayfixtureprojref_config/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches", + "path": "/v2/projects/replayfixtureprojref/config", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v2_projects___PROJECT_REF___config/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v2_projects_replayfixtureprojref_config/default.response.json similarity index 99% rename from apps/cli-e2e/fixtures/recorded/GET_v2_projects___PROJECT_REF___config/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v2_projects_replayfixtureprojref_config/default.response.json index 32f2d66cda..3bddcd789d 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v2_projects___PROJECT_REF___config/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v2_projects_replayfixtureprojref_config/default.response.json @@ -10,7 +10,7 @@ "body": { "data": { "type": "project_config", - "id": "__PROJECT_REF__", + "id": "replayfixtureprojref", "attributes": { "database": { "major_version": 15, diff --git a/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___config_auth/default.request.json b/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_config_auth/default.request.json similarity index 86% rename from apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___config_auth/default.request.json rename to apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_config_auth/default.request.json index 8ebc770e78..01e5bd27dd 100644 --- a/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___config_auth/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_config_auth/default.request.json @@ -1,6 +1,6 @@ { "method": "PATCH", - "path": "/v1/projects/__PROJECT_REF__/config/auth", + "path": "/v1/projects/replayfixtureprojref/config/auth", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___config_auth/default.response.json b/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_config_auth/default.response.json similarity index 99% rename from apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___config_auth/default.response.json rename to apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_config_auth/default.response.json index 63f4b7dc8d..402a0c2d5b 100644 --- a/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___config_auth/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_config_auth/default.response.json @@ -40,6 +40,7 @@ "hook_before_user_created_secrets": null, "nimbus_oauth_client_id": null, "nimbus_oauth_client_secret": null, + "nimbus_oauth_email_optional": null, "external_anonymous_users_enabled": false, "external_apple_additional_client_ids": null, "external_apple_client_id": null, diff --git a/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___network_restrictions/default.request.json b/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_network_restrictions/default.request.json similarity index 84% rename from apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___network_restrictions/default.request.json rename to apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_network_restrictions/default.request.json index b1d6463463..b9ec02a375 100644 --- a/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___network_restrictions/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_network_restrictions/default.request.json @@ -1,6 +1,6 @@ { "method": "PATCH", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions", + "path": "/v1/projects/replayfixtureprojref/network-restrictions", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___network_restrictions/default.response.json b/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_network_restrictions/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___network_restrictions/default.response.json rename to apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_network_restrictions/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_bucket/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_bucket/default.response.json index 9bf59f4e78..432b92f55b 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_bucket/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_bucket/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_cached_txt/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_cached_txt/default.response.json index 1279a44a56..fddaa17bee 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_cached_txt/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_cached_txt/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_mv_source_txt/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_mv_source_txt/default.response.json index 76e3cededd..92061e714b 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_mv_source_txt/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_mv_source_txt/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_a_txt/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_a_txt/default.response.json index 242bf593da..6f62d80f96 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_a_txt/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_a_txt/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_b_txt/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_b_txt/default.response.json index f24511c4ec..ad416ae65a 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_b_txt/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_b_txt/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_target_txt/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_target_txt/default.response.json index ca29aa9df7..7a4788e754 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_target_txt/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_target_txt/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_typed_txt/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_typed_txt/default.response.json index 64a5469fab..89f4f33a19 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_typed_txt/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_typed_txt/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_upload_txt/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_upload_txt/default.response.json index c593b75784..1b0f72e07a 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_upload_txt/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_upload_txt/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_list_cli_e2e_bucket/2.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_list_cli_e2e_bucket/2.response.json index 8f23f442a8..2267284f45 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_list_cli_e2e_bucket/2.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_list_cli_e2e_bucket/2.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_list_cli_e2e_bucket/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_list_cli_e2e_bucket/default.response.json index 8f23f442a8..2267284f45 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_list_cli_e2e_bucket/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_list_cli_e2e_bucket/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_move/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_move/default.response.json index 287142abae..5b96815328 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_move/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_move/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_organizations/2.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_organizations/2.response.json index ef71d3d4a3..a3226e0bed 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_organizations/2.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_organizations/2.response.json @@ -4,8 +4,8 @@ "content-type": "application/json; charset=utf-8" }, "body": { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "Test Org" } } diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_organizations/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_organizations/default.response.json index ef71d3d4a3..a3226e0bed 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_organizations/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_organizations/default.response.json @@ -4,8 +4,8 @@ "content-type": "application/json; charset=utf-8" }, "body": { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "Test Org" } } diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects/2.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects/2.request.json index a7217c7a63..f14b19f76c 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects/2.request.json @@ -14,7 +14,7 @@ "db_pass": "password123", "desired_instance_size": "micro", "name": "my-project", - "organization_slug": "__PROJECT_REF__", + "organization_slug": "replayfixtureprojref", "region": "us-east-1" } } diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects/2.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects/2.response.json index 3a91da27ab..e6ddc2538a 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects/2.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects/2.response.json @@ -8,10 +8,10 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "my-project", "region": "us-east-1", "created_at": "2000-01-01T00:00:00Z", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects/default.request.json index 855525e4e8..52689d52c7 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects/default.request.json @@ -13,7 +13,7 @@ "body": { "db_pass": "cli-e2e-password-123", "name": "cli-e2e-test", - "organization_slug": "__PROJECT_REF__", + "organization_slug": "replayfixtureprojref", "region": "us-east-1" } } diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects/default.response.json index 0c132719e4..8f6668f291 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects/default.response.json @@ -8,10 +8,10 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "created_at": "2000-01-01T00:00:00Z", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___branches/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_branches/default.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___branches/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_branches/default.request.json index 5c7a1c1d49..077d425cd2 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___branches/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_branches/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/branches", + "path": "/v1/projects/replayfixtureprojref/branches", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___branches/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_branches/default.response.json similarity index 92% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___branches/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_branches/default.response.json index 990c8311c1..848c25491f 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___branches/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_branches/default.response.json @@ -11,7 +11,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___config_auth_sso_providers/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.request.json similarity index 91% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___config_auth_sso_providers/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.request.json index acbd431466..579e0fa208 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___config_auth_sso_providers/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___config_auth_sso_providers/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___config_auth_sso_providers/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___custom_hostname_activate/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_custom_hostname_activate/default.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___custom_hostname_activate/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_custom_hostname_activate/default.request.json index a9eef9b8a3..8cfca20bf2 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___custom_hostname_activate/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_custom_hostname_activate/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/activate", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/activate", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___custom_hostname_activate/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_custom_hostname_activate/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___custom_hostname_activate/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_custom_hostname_activate/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___custom_hostname_reverify/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_custom_hostname_reverify/default.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___custom_hostname_reverify/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_custom_hostname_reverify/default.request.json index 35af27f8dc..c5f8e259db 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___custom_hostname_reverify/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_custom_hostname_reverify/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/reverify", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/reverify", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___custom_hostname/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_custom_hostname_reverify/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___custom_hostname/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_custom_hostname_reverify/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_backups_restore_pitr/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_backups_restore_pitr/default.request.json similarity index 81% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_backups_restore_pitr/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_backups_restore_pitr/default.request.json index d3070b2b28..7b0a23b9ae 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_backups_restore_pitr/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_backups_restore_pitr/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/database/backups/restore-pitr", + "path": "/v1/projects/replayfixtureprojref/database/backups/restore-pitr", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_backups_restore_pitr/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_backups_restore_pitr/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_backups_restore_pitr/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_backups_restore_pitr/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_query/2.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_query/2.request.json similarity index 83% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_query/2.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_query/2.request.json index 3454252ccf..0ee6600339 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_query/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_query/2.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/database/query", + "path": "/v1/projects/replayfixtureprojref/database/query", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_query/2.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_query/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_query/2.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_query/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_query/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_query/default.request.json similarity index 83% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_query/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_query/default.request.json index 3454252ccf..0ee6600339 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_query/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_query/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/database/query", + "path": "/v1/projects/replayfixtureprojref/database/query", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_query/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_query/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_query/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_query/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_webhooks_enable/2.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_webhooks_enable/2.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_webhooks_enable/2.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_webhooks_enable/2.request.json index d717912fd9..db5d564a4b 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_webhooks_enable/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_webhooks_enable/2.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/database/webhooks/enable", + "path": "/v1/projects/replayfixtureprojref/database/webhooks/enable", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_webhooks_enable/2.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_webhooks_enable/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_webhooks_enable/2.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_webhooks_enable/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_webhooks_enable/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_webhooks_enable/default.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_webhooks_enable/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_webhooks_enable/default.request.json index d717912fd9..db5d564a4b 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_webhooks_enable/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_webhooks_enable/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/database/webhooks/enable", + "path": "/v1/projects/replayfixtureprojref/database/webhooks/enable", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_webhooks_enable/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_webhooks_enable/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/2.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/2.request.json similarity index 78% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/2.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/2.request.json index 344e0332a5..add8a6037a 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/2.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/network-bans/retrieve", + "path": "/v1/projects/replayfixtureprojref/network-bans/retrieve", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/2.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/2.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/3.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/3.request.json similarity index 78% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/3.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/3.request.json index 344e0332a5..add8a6037a 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/3.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/network-bans/retrieve", + "path": "/v1/projects/replayfixtureprojref/network-bans/retrieve", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/3.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/3.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/default.request.json similarity index 78% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/default.request.json index 344e0332a5..add8a6037a 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/network-bans/retrieve", + "path": "/v1/projects/replayfixtureprojref/network-bans/retrieve", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_restrictions_apply/2.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_restrictions_apply/2.request.json similarity index 82% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_restrictions_apply/2.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_restrictions_apply/2.request.json index a51f304ada..60e3c04faf 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_restrictions_apply/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_restrictions_apply/2.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions/apply", + "path": "/v1/projects/replayfixtureprojref/network-restrictions/apply", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_restrictions_apply/2.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_restrictions_apply/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_restrictions_apply/2.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_restrictions_apply/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_restrictions_apply/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_restrictions_apply/default.request.json similarity index 82% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_restrictions_apply/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_restrictions_apply/default.request.json index a51f304ada..60e3c04faf 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_restrictions_apply/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_restrictions_apply/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions/apply", + "path": "/v1/projects/replayfixtureprojref/network-restrictions/apply", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_restrictions_apply/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_restrictions_apply/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_restrictions_apply/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_restrictions_apply/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/2.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/2.request.json similarity index 87% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/2.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/2.request.json index 130d9ea18f..7ca6da58e7 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/2.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/2.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/2.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/3.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/3.request.json similarity index 86% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/3.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/3.request.json index 9ee9df174b..6cad61e34b 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/3.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/3.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/3.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/4.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/4.request.json similarity index 87% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/4.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/4.request.json index 641a391fa8..5505f2dff0 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/4.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/4.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/4.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/4.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/4.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/4.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/default.request.json similarity index 86% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/default.request.json index 9ee9df174b..6cad61e34b 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_webhooks_enable/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_webhooks_enable/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___vanity_subdomain_activate/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_vanity_subdomain_activate/default.request.json similarity index 81% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___vanity_subdomain_activate/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_vanity_subdomain_activate/default.request.json index 6716dd9f58..6d29cb267c 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___vanity_subdomain_activate/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_vanity_subdomain_activate/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain/activate", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain/activate", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___vanity_subdomain_activate/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_vanity_subdomain_activate/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___vanity_subdomain_activate/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_vanity_subdomain_activate/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___vanity_subdomain_check_availability/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_vanity_subdomain_check_availability/default.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___vanity_subdomain_check_availability/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_vanity_subdomain_check_availability/default.request.json index a03f31e848..a246af8048 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___vanity_subdomain_check_availability/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_vanity_subdomain_check_availability/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain/check-availability", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain/check-availability", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___vanity_subdomain_check_availability/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_vanity_subdomain_check_availability/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___vanity_subdomain_check_availability/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_vanity_subdomain_check_availability/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___config_database_postgres/default.request.json b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_config_database_postgres/default.request.json similarity index 85% rename from apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___config_database_postgres/default.request.json rename to apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_config_database_postgres/default.request.json index cecea59539..e88bf17818 100644 --- a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___config_database_postgres/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_config_database_postgres/default.request.json @@ -1,6 +1,6 @@ { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/default.response.json b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_config_database_postgres/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/default.response.json rename to apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_config_database_postgres/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___pgsodium/default.request.json b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_pgsodium/default.request.json similarity index 84% rename from apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___pgsodium/default.request.json rename to apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_pgsodium/default.request.json index 856d5f59dd..9b04cd8096 100644 --- a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___pgsodium/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_pgsodium/default.request.json @@ -1,6 +1,6 @@ { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/pgsodium", + "path": "/v1/projects/replayfixtureprojref/pgsodium", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___pgsodium/default.response.json b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_pgsodium/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___pgsodium/default.response.json rename to apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_pgsodium/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___ssl_enforcement/2.request.json b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_ssl_enforcement/2.request.json similarity index 84% rename from apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___ssl_enforcement/2.request.json rename to apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_ssl_enforcement/2.request.json index bf5be40386..0e8caa071c 100644 --- a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___ssl_enforcement/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_ssl_enforcement/2.request.json @@ -1,6 +1,6 @@ { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/ssl-enforcement", + "path": "/v1/projects/replayfixtureprojref/ssl-enforcement", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___ssl_enforcement/2.response.json b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_ssl_enforcement/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___ssl_enforcement/2.response.json rename to apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_ssl_enforcement/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___ssl_enforcement/default.request.json b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_ssl_enforcement/default.request.json similarity index 84% rename from apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___ssl_enforcement/default.request.json rename to apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_ssl_enforcement/default.request.json index 9e6c154878..eccda0b75f 100644 --- a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___ssl_enforcement/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_ssl_enforcement/default.request.json @@ -1,6 +1,6 @@ { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/ssl-enforcement", + "path": "/v1/projects/replayfixtureprojref/ssl-enforcement", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/default.response.json b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_ssl_enforcement/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/default.response.json rename to apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_ssl_enforcement/default.response.json diff --git a/apps/cli-e2e/fixtures/scenarios/backups-list-debug-shows-http-trace/interactions.json b/apps/cli-e2e/fixtures/scenarios/backups-list-debug-shows-http-trace/interactions.json index 360ca3a072..e5147cf38b 100644 --- a/apps/cli-e2e/fixtures/scenarios/backups-list-debug-shows-http-trace/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/backups-list-debug-shows-http-trace/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/database/backups", + "path": "/v1/projects/replayfixtureprojref/database/backups", "query": {}, "headers": { "accept-encoding": "gzip", @@ -33,7 +33,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -53,15 +53,15 @@ "x-ratelimit-reset": "7" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/backups-list-renders-backup-table-with-region-column/interactions.json b/apps/cli-e2e/fixtures/scenarios/backups-list-renders-backup-table-with-region-column/interactions.json index 9bbd6bee94..4182b529ed 100644 --- a/apps/cli-e2e/fixtures/scenarios/backups-list-renders-backup-table-with-region-column/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/backups-list-renders-backup-table-with-region-column/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/database/backups", + "path": "/v1/projects/replayfixtureprojref/database/backups", "query": {}, "headers": { "accept-encoding": "gzip", @@ -33,7 +33,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -53,15 +53,15 @@ "x-ratelimit-reset": "9" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/backups-list-returns-backup-response-as-json-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/backups-list-returns-backup-response-as-json-with-output-json/interactions.json index 76639b1437..e537d665d9 100644 --- a/apps/cli-e2e/fixtures/scenarios/backups-list-returns-backup-response-as-json-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/backups-list-returns-backup-response-as-json-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/database/backups", + "path": "/v1/projects/replayfixtureprojref/database/backups", "query": {}, "headers": { "accept-encoding": "gzip", @@ -33,7 +33,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -53,15 +53,15 @@ "x-ratelimit-reset": "8" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/backups-restore-initiates-pitr-restore-with-t-timestamp/interactions.json b/apps/cli-e2e/fixtures/scenarios/backups-restore-initiates-pitr-restore-with-t-timestamp/interactions.json index 3af3c58289..b0545b4247 100644 --- a/apps/cli-e2e/fixtures/scenarios/backups-restore-initiates-pitr-restore-with-t-timestamp/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/backups-restore-initiates-pitr-restore-with-t-timestamp/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/database/backups/restore-pitr", + "path": "/v1/projects/replayfixtureprojref/database/backups/restore-pitr", "query": {}, "headers": { "accept-encoding": "gzip", @@ -31,7 +31,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -51,15 +51,15 @@ "x-ratelimit-reset": "51" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/branches-create-creates-branch-with-data/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-create-creates-branch-with-data/interactions.json index 9d25a7819b..62dc4ef547 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-create-creates-branch-with-data/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-create-creates-branch-with-data/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/branches", + "path": "/v1/projects/replayfixtureprojref/branches", "query": {}, "headers": { "accept-encoding": "gzip", @@ -25,7 +25,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, diff --git a/apps/cli-e2e/fixtures/scenarios/branches-create-creates-ephemeral-branch/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-create-creates-ephemeral-branch/interactions.json index 9d25a7819b..62dc4ef547 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-create-creates-ephemeral-branch/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-create-creates-ephemeral-branch/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/branches", + "path": "/v1/projects/replayfixtureprojref/branches", "query": {}, "headers": { "accept-encoding": "gzip", @@ -25,7 +25,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, diff --git a/apps/cli-e2e/fixtures/scenarios/branches-create-creates-persistent-branch/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-create-creates-persistent-branch/interactions.json index 9d25a7819b..62dc4ef547 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-create-creates-persistent-branch/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-create-creates-persistent-branch/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/branches", + "path": "/v1/projects/replayfixtureprojref/branches", "query": {}, "headers": { "accept-encoding": "gzip", @@ -25,7 +25,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, diff --git a/apps/cli-e2e/fixtures/scenarios/branches-delete-deletes-branch-successfully/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-delete-deletes-branch-successfully/interactions.json index 871a962780..dd3c829210 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-delete-deletes-branch-successfully/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-delete-deletes-branch-successfully/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches/my-branch", + "path": "/v1/projects/replayfixtureprojref/branches/my-branch", "query": {}, "headers": { "accept-encoding": "gzip", @@ -25,7 +25,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, @@ -39,7 +39,7 @@ { "request": { "method": "DELETE", - "path": "/v1/branches/__PROJECT_REF__", + "path": "/v1/branches/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/branches-disable-disables-preview-branching/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-disable-disables-preview-branching/interactions.json index 1e505fa72f..d37146b00d 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-disable-disables-preview-branching/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-disable-disables-preview-branching/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/branches", + "path": "/v1/projects/replayfixtureprojref/branches", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/branches-get-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-get-returns-json-output-with-output-json/interactions.json index 7498935db3..6d6e83ea58 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-get-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-get-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches/my-branch", + "path": "/v1/projects/replayfixtureprojref/branches/my-branch", "query": {}, "headers": { "accept-encoding": "gzip", @@ -25,7 +25,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, @@ -39,7 +39,7 @@ { "request": { "method": "GET", - "path": "/v1/branches/__PROJECT_REF__", + "path": "/v1/branches/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -75,7 +75,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/api-keys", + "path": "/v1/projects/replayfixtureprojref/api-keys", "query": {}, "headers": { "accept-encoding": "gzip", @@ -103,7 +103,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/pooler", + "path": "/v1/projects/replayfixtureprojref/config/database/pooler", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/branches-get-returns-single-branch-details/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-get-returns-single-branch-details/interactions.json index 7c5439bc46..ea9bb84c5a 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-get-returns-single-branch-details/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-get-returns-single-branch-details/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches/my-branch", + "path": "/v1/projects/replayfixtureprojref/branches/my-branch", "query": {}, "headers": { "accept-encoding": "gzip", @@ -25,7 +25,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, @@ -39,7 +39,7 @@ { "request": { "method": "GET", - "path": "/v1/branches/__PROJECT_REF__", + "path": "/v1/branches/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/branches-list-includes-debug-output-with-debug/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-list-includes-debug-output-with-debug/interactions.json index 0cd93eb081..05c9ecbf07 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-list-includes-debug-output-with-debug/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-list-includes-debug-output-with-debug/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches", + "path": "/v1/projects/replayfixtureprojref/branches", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "58" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/branches-list-renders-fixture-data-in-output/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-list-renders-fixture-data-in-output/interactions.json index 28ff6c9a6d..7f9fdccaa9 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-list-renders-fixture-data-in-output/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-list-renders-fixture-data-in-output/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches", + "path": "/v1/projects/replayfixtureprojref/branches", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "59" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/branches-list-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-list-returns-json-output-with-output-json/interactions.json index 7473a87935..c9fe7ed496 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-list-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-list-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches", + "path": "/v1/projects/replayfixtureprojref/branches", "query": {}, "headers": { "accept-encoding": "gzip", @@ -26,7 +26,7 @@ "id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", "name": "test-branch", "project_ref": "branchrefaaaaaaaaa", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "persistent": false, "status": "MIGRATIONS_PASSED", diff --git a/apps/cli-e2e/fixtures/scenarios/branches-pause-pauses-branch-successfully/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-pause-pauses-branch-successfully/interactions.json index b5e18d2b8f..44802e0818 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-pause-pauses-branch-successfully/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-pause-pauses-branch-successfully/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches/my-branch", + "path": "/v1/projects/replayfixtureprojref/branches/my-branch", "query": {}, "headers": { "accept-encoding": "gzip", @@ -25,7 +25,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, @@ -39,7 +39,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/pause", + "path": "/v1/projects/replayfixtureprojref/pause", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/branches-unpause-unpauses-branch-successfully/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-unpause-unpauses-branch-successfully/interactions.json index 643c6faf30..d964ef6a8b 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-unpause-unpauses-branch-successfully/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-unpause-unpauses-branch-successfully/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches/my-branch", + "path": "/v1/projects/replayfixtureprojref/branches/my-branch", "query": {}, "headers": { "accept-encoding": "gzip", @@ -25,7 +25,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, @@ -39,7 +39,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/restore", + "path": "/v1/projects/replayfixtureprojref/restore", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/branches-update-changes-git-branch-with-git-branch/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-update-changes-git-branch-with-git-branch/interactions.json index fcfc83154e..09583c5f6c 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-update-changes-git-branch-with-git-branch/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-update-changes-git-branch-with-git-branch/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches/my-branch", + "path": "/v1/projects/replayfixtureprojref/branches/my-branch", "query": {}, "headers": { "accept-encoding": "gzip", @@ -25,7 +25,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, @@ -39,7 +39,7 @@ { "request": { "method": "PATCH", - "path": "/v1/branches/__PROJECT_REF__", + "path": "/v1/branches/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -62,7 +62,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, diff --git a/apps/cli-e2e/fixtures/scenarios/branches-update-renames-branch-with-name/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-update-renames-branch-with-name/interactions.json index fcfc83154e..09583c5f6c 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-update-renames-branch-with-name/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-update-renames-branch-with-name/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches/my-branch", + "path": "/v1/projects/replayfixtureprojref/branches/my-branch", "query": {}, "headers": { "accept-encoding": "gzip", @@ -25,7 +25,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, @@ -39,7 +39,7 @@ { "request": { "method": "PATCH", - "path": "/v1/branches/__PROJECT_REF__", + "path": "/v1/branches/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -62,7 +62,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, diff --git a/apps/cli-e2e/fixtures/scenarios/config-push-emits-http-trace-with-debug/interactions.json b/apps/cli-e2e/fixtures/scenarios/config-push-emits-http-trace-with-debug/interactions.json index 686b20071a..3592f95830 100644 --- a/apps/cli-e2e/fixtures/scenarios/config-push-emits-http-trace-with-debug/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/config-push-emits-http-trace-with-debug/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/billing/addons", + "path": "/v1/projects/replayfixtureprojref/billing/addons", "query": {}, "headers": { "accept-encoding": "gzip", @@ -745,7 +745,7 @@ { "request": { "method": "GET", - "path": "/v2/projects/__PROJECT_REF__/config", + "path": "/v2/projects/replayfixtureprojref/config", "query": {}, "headers": { "accept-encoding": "gzip", @@ -767,7 +767,7 @@ "body": { "data": { "type": "project_config", - "id": "__PROJECT_REF__", + "id": "replayfixtureprojref", "attributes": { "database": { "major_version": 15, diff --git a/apps/cli-e2e/fixtures/scenarios/config-push-reconciles-every-section-against-the-remote/interactions.json b/apps/cli-e2e/fixtures/scenarios/config-push-reconciles-every-section-against-the-remote/interactions.json index e7b18825fa..08cd367f99 100644 --- a/apps/cli-e2e/fixtures/scenarios/config-push-reconciles-every-section-against-the-remote/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/config-push-reconciles-every-section-against-the-remote/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/billing/addons", + "path": "/v1/projects/replayfixtureprojref/billing/addons", "query": {}, "headers": { "accept-encoding": "gzip", @@ -745,7 +745,7 @@ { "request": { "method": "GET", - "path": "/v2/projects/__PROJECT_REF__/config", + "path": "/v2/projects/replayfixtureprojref/config", "query": {}, "headers": { "accept-encoding": "gzip", @@ -767,7 +767,7 @@ "body": { "data": { "type": "project_config", - "id": "__PROJECT_REF__", + "id": "replayfixtureprojref", "attributes": { "database": { "major_version": 15, diff --git a/apps/cli-e2e/fixtures/scenarios/db-advisors-all-returns-advisors-with-type-all/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-advisors-all-returns-advisors-with-type-all/interactions.json index 58eb2e731d..aeb0278cbf 100644 --- a/apps/cli-e2e/fixtures/scenarios/db-advisors-all-returns-advisors-with-type-all/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/db-advisors-all-returns-advisors-with-type-all/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/advisors/security", + "path": "/v1/projects/replayfixtureprojref/advisors/security", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/advisors/performance", + "path": "/v1/projects/replayfixtureprojref/advisors/performance", "query": {}, "headers": { "accept-encoding": "gzip", @@ -72,7 +72,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -92,15 +92,15 @@ "x-ratelimit-reset": "39" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/db-advisors-performance-returns-performance-advisors/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-advisors-performance-returns-performance-advisors/interactions.json index 368bc1d273..d5412f7e68 100644 --- a/apps/cli-e2e/fixtures/scenarios/db-advisors-performance-returns-performance-advisors/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/db-advisors-performance-returns-performance-advisors/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/advisors/performance", + "path": "/v1/projects/replayfixtureprojref/advisors/performance", "query": {}, "headers": { "accept-encoding": "gzip", @@ -45,7 +45,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -65,15 +65,15 @@ "x-ratelimit-reset": "46" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/db-advisors-security-exits-zero-when-fail-on-error-and-no-error-level-advisors-found/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-advisors-security-exits-zero-when-fail-on-error-and-no-error-level-advisors-found/interactions.json index 618219cbdb..ed58859a34 100644 --- a/apps/cli-e2e/fixtures/scenarios/db-advisors-security-exits-zero-when-fail-on-error-and-no-error-level-advisors-found/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/db-advisors-security-exits-zero-when-fail-on-error-and-no-error-level-advisors-found/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/advisors/security", + "path": "/v1/projects/replayfixtureprojref/advisors/security", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "50" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/db-advisors-security-returns-security-advisors/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-advisors-security-returns-security-advisors/interactions.json index 0a841590a9..0790d147ea 100644 --- a/apps/cli-e2e/fixtures/scenarios/db-advisors-security-returns-security-advisors/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/db-advisors-security-returns-security-advisors/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/advisors/security", + "path": "/v1/projects/replayfixtureprojref/advisors/security", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "54" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/db-push-dry-run-exits-non-zero-on-connection-refused-with-dry-run/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-push-dry-run-exits-non-zero-on-connection-refused-with-dry-run/interactions.json index 2889c5be55..4596be6911 100644 --- a/apps/cli-e2e/fixtures/scenarios/db-push-dry-run-exits-non-zero-on-connection-refused-with-dry-run/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/db-push-dry-run-exits-non-zero-on-connection-refused-with-dry-run/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -22,15 +22,15 @@ "x-ratelimit-reset": "31" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/db-push-linked-exits-non-zero-on-connection-refused-with-linked/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-push-linked-exits-non-zero-on-connection-refused-with-linked/interactions.json index d1412357ea..9b6c2a13e7 100644 --- a/apps/cli-e2e/fixtures/scenarios/db-push-linked-exits-non-zero-on-connection-refused-with-linked/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/db-push-linked-exits-non-zero-on-connection-refused-with-linked/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -22,15 +22,15 @@ "x-ratelimit-reset": "30" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-json-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-json-with-output-json/interactions.json index 4967bb9666..4ce1bb15f0 100644 --- a/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-json-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-json-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/database/query", + "path": "/v1/projects/replayfixtureprojref/database/query", "query": {}, "headers": { "accept-encoding": "gzip", @@ -36,7 +36,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -56,15 +56,15 @@ "x-ratelimit-reset": "34" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-select-1-result-in-table-format/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-select-1-result-in-table-format/interactions.json index e1355cab3a..533edf3271 100644 --- a/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-select-1-result-in-table-format/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-select-1-result-in-table-format/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/database/query", + "path": "/v1/projects/replayfixtureprojref/database/query", "query": {}, "headers": { "accept-encoding": "gzip", @@ -36,7 +36,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -56,15 +56,15 @@ "x-ratelimit-reset": "36" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/domains-activate-custom-domain-disabled/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-activate-custom-domain-disabled/interactions.json index 02a9fe2987..cc2b4bca6c 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-activate-custom-domain-disabled/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-activate-custom-domain-disabled/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/activate", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/activate", "query": {}, "headers": { "accept-encoding": "gzip", @@ -30,7 +30,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-activate-domain-activated/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-activate-domain-activated/interactions.json index c41eb52db4..f07ccb4454 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-activate-domain-activated/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-activate-domain-activated/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/activate", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/activate", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-activate-no-custom-domain/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-activate-no-custom-domain/interactions.json index 354087c82e..18c0f0d7a2 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-activate-no-custom-domain/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-activate-no-custom-domain/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/activate", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/activate", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-activate-pending-verification-in-debug-mode/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-activate-pending-verification-in-debug-mode/interactions.json index 354087c82e..18c0f0d7a2 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-activate-pending-verification-in-debug-mode/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-activate-pending-verification-in-debug-mode/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/activate", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/activate", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-activate-pending-verification/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-activate-pending-verification/interactions.json index 354087c82e..18c0f0d7a2 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-activate-pending-verification/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-activate-pending-verification/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/activate", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/activate", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-activate-verification-completed/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-activate-verification-completed/interactions.json index d783527baf..338a6bea43 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-activate-verification-completed/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-activate-verification-completed/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/activate", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/activate", "query": {}, "headers": { "accept-encoding": "gzip", @@ -28,7 +28,7 @@ "errors": [], "messages": [], "result": { - "custom_origin_server": "__PROJECT_REF__.supabase.red", + "custom_origin_server": "replayfixtureprojref.supabase.red", "hostname": "www.urgsimurksi.xyz", "id": "00000000-0000-0000-0000-000000000000", "ownership_verification": { diff --git a/apps/cli-e2e/fixtures/scenarios/domains-create-custom-domain-disabled/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-create-custom-domain-disabled/interactions.json index 1393e7690d..06a13a31bb 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-create-custom-domain-disabled/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-create-custom-domain-disabled/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/initialize", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/initialize", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-create-no-custom-domain/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-create-no-custom-domain/interactions.json index c7bc63fd98..d0f5af268e 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-create-no-custom-domain/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-create-no-custom-domain/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/initialize", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/initialize", "query": {}, "headers": { "accept-encoding": "gzip", @@ -31,7 +31,7 @@ "errors": [], "messages": [], "result": { - "custom_origin_server": "__PROJECT_REF__.supabase.red", + "custom_origin_server": "replayfixtureprojref.supabase.red", "hostname": "www.urgsimurksi.xyz", "id": "00000000-0000-0000-0000-000000000000", "ownership_verification": { diff --git a/apps/cli-e2e/fixtures/scenarios/domains-create-pending-verification/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-create-pending-verification/interactions.json index 46cccbbcd8..5656b16f99 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-create-pending-verification/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-create-pending-verification/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/initialize", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/initialize", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-delete-custom-domain-disabled/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-delete-custom-domain-disabled/interactions.json index 6975d1015f..442f415664 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-delete-custom-domain-disabled/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-delete-custom-domain-disabled/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname", + "path": "/v1/projects/replayfixtureprojref/custom-hostname", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-delete-no-custom-domain/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-delete-no-custom-domain/interactions.json index e3df425d0d..1f199f9c41 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-delete-no-custom-domain/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-delete-no-custom-domain/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname", + "path": "/v1/projects/replayfixtureprojref/custom-hostname", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-delete-pending-verification/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-delete-pending-verification/interactions.json index b74f58cdc1..f5d1309b44 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-delete-pending-verification/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-delete-pending-verification/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname", + "path": "/v1/projects/replayfixtureprojref/custom-hostname", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-get-domain-activated/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-get-domain-activated/interactions.json index 79e938bff6..1ba4fcad8f 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-get-domain-activated/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-get-domain-activated/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname", + "path": "/v1/projects/replayfixtureprojref/custom-hostname", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ "errors": [], "messages": [], "result": { - "custom_origin_server": "__PROJECT_REF__.supabase.red", + "custom_origin_server": "replayfixtureprojref.supabase.red", "hostname": "www.urgsimurksi.xyz", "id": "00000000-0000-0000-0000-000000000000", "ownership_verification": { diff --git a/apps/cli-e2e/fixtures/scenarios/domains-get-no-custom-domain/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-get-no-custom-domain/interactions.json index b5bb43ac50..5ed8dc1793 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-get-no-custom-domain/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-get-no-custom-domain/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname", + "path": "/v1/projects/replayfixtureprojref/custom-hostname", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-get-pending-verification/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-get-pending-verification/interactions.json index 6e83f89315..626a8a57d7 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-get-pending-verification/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-get-pending-verification/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname", + "path": "/v1/projects/replayfixtureprojref/custom-hostname", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ "errors": [], "messages": [], "result": { - "custom_origin_server": "__PROJECT_REF__.supabase.red", + "custom_origin_server": "replayfixtureprojref.supabase.red", "hostname": "www.urgsimurksi.xyz", "id": "00000000-0000-0000-0000-000000000000", "ownership_verification": { diff --git a/apps/cli-e2e/fixtures/scenarios/domains-get-verification-completed/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-get-verification-completed/interactions.json index 7d6fc488c8..51f214ef65 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-get-verification-completed/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-get-verification-completed/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname", + "path": "/v1/projects/replayfixtureprojref/custom-hostname", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ "errors": [], "messages": [], "result": { - "custom_origin_server": "__PROJECT_REF__.supabase.red", + "custom_origin_server": "replayfixtureprojref.supabase.red", "hostname": "www.urgsimurksi.xyz", "id": "00000000-0000-0000-0000-000000000000", "ownership_verification": { diff --git a/apps/cli-e2e/fixtures/scenarios/domains-reverify-custom-domain-disabled/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-reverify-custom-domain-disabled/interactions.json index 55a2b46ff1..52a31c8eec 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-reverify-custom-domain-disabled/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-reverify-custom-domain-disabled/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/reverify", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/reverify", "query": {}, "headers": { "accept-encoding": "gzip", @@ -30,7 +30,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-reverify-no-custom-domain/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-reverify-no-custom-domain/interactions.json index 15f0188362..e917aea913 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-reverify-no-custom-domain/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-reverify-no-custom-domain/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/reverify", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/reverify", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-reverify-pending-verification/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-reverify-pending-verification/interactions.json index d666d06ff4..142df388eb 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-reverify-pending-verification/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-reverify-pending-verification/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/reverify", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/reverify", "query": {}, "headers": { "accept-encoding": "gzip", @@ -28,7 +28,7 @@ "errors": [], "messages": [], "result": { - "custom_origin_server": "__PROJECT_REF__.supabase.red", + "custom_origin_server": "replayfixtureprojref.supabase.red", "hostname": "www.urgsimurksi.xyz", "id": "00000000-0000-0000-0000-000000000000", "ownership_verification": { diff --git a/apps/cli-e2e/fixtures/scenarios/domains-reverify-verification-completed/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-reverify-verification-completed/interactions.json index db385be37a..7b954b67e3 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-reverify-verification-completed/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-reverify-verification-completed/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/reverify", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/reverify", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-debug-shows-http-trace/interactions.json b/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-debug-shows-http-trace/interactions.json index f7899f6aa1..15c42e3fa0 100644 --- a/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-debug-shows-http-trace/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-debug-shows-http-trace/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/pgsodium", + "path": "/v1/projects/replayfixtureprojref/pgsodium", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "11" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-renders-root-encryption-key/interactions.json b/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-renders-root-encryption-key/interactions.json index 331e7aaeb0..3be438193f 100644 --- a/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-renders-root-encryption-key/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-renders-root-encryption-key/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/pgsodium", + "path": "/v1/projects/replayfixtureprojref/pgsodium", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "14" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-returns-root-key-as-json-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-returns-root-key-as-json-with-output-json/interactions.json index f4f72607bf..47c57166e0 100644 --- a/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-returns-root-key-as-json-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-returns-root-key-as-json-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/pgsodium", + "path": "/v1/projects/replayfixtureprojref/pgsodium", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "13" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/encryption-update-root-key-rotates-the-vault-root-key/interactions.json b/apps/cli-e2e/fixtures/scenarios/encryption-update-root-key-rotates-the-vault-root-key/interactions.json index 8c29424805..bb79bf9174 100644 --- a/apps/cli-e2e/fixtures/scenarios/encryption-update-root-key-rotates-the-vault-root-key/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/encryption-update-root-key-rotates-the-vault-root-key/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/pgsodium", + "path": "/v1/projects/replayfixtureprojref/pgsodium", "query": {}, "headers": { "accept-encoding": "gzip", @@ -33,7 +33,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -53,15 +53,15 @@ "x-ratelimit-reset": "58" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/functions-delete-deletes-function-successfully/interactions.json b/apps/cli-e2e/fixtures/scenarios/functions-delete-deletes-function-successfully/interactions.json index 6a13d5e336..e3b75f5ae0 100644 --- a/apps/cli-e2e/fixtures/scenarios/functions-delete-deletes-function-successfully/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/functions-delete-deletes-function-successfully/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/functions/hello-world", + "path": "/v1/projects/replayfixtureprojref/functions/hello-world", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/functions-download-downloads-function-successfully/interactions.json b/apps/cli-e2e/fixtures/scenarios/functions-download-downloads-function-successfully/interactions.json index fbf89396ff..9faa0f7346 100644 --- a/apps/cli-e2e/fixtures/scenarios/functions-download-downloads-function-successfully/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/functions-download-downloads-function-successfully/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/functions/hello-world/body", + "path": "/v1/projects/replayfixtureprojref/functions/hello-world/body", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/functions-list-includes-debug-output-with-debug/interactions.json b/apps/cli-e2e/fixtures/scenarios/functions-list-includes-debug-output-with-debug/interactions.json index 408e4a81bc..1006d84cc7 100644 --- a/apps/cli-e2e/fixtures/scenarios/functions-list-includes-debug-output-with-debug/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/functions-list-includes-debug-output-with-debug/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/functions", + "path": "/v1/projects/replayfixtureprojref/functions", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "24" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/functions-list-renders-fixture-data-in-output/interactions.json b/apps/cli-e2e/fixtures/scenarios/functions-list-renders-fixture-data-in-output/interactions.json index 7ed0e0e659..1e18d74308 100644 --- a/apps/cli-e2e/fixtures/scenarios/functions-list-renders-fixture-data-in-output/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/functions-list-renders-fixture-data-in-output/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/functions", + "path": "/v1/projects/replayfixtureprojref/functions", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "26" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/functions-list-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/functions-list-returns-json-output-with-output-json/interactions.json index bfb106ed97..4c9525f804 100644 --- a/apps/cli-e2e/fixtures/scenarios/functions-list-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/functions-list-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/functions", + "path": "/v1/projects/replayfixtureprojref/functions", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "25" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/gen-types-generates-typescript-types-from-project/interactions.json b/apps/cli-e2e/fixtures/scenarios/gen-types-generates-typescript-types-from-project/interactions.json index 07cda5a499..513d2118ed 100644 --- a/apps/cli-e2e/fixtures/scenarios/gen-types-generates-typescript-types-from-project/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/gen-types-generates-typescript-types-from-project/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/types/typescript", + "path": "/v1/projects/replayfixtureprojref/types/typescript", "query": { "included_schemas": "public" }, diff --git a/apps/cli-e2e/fixtures/scenarios/gen-types-includes-debug-output-with-debug/interactions.json b/apps/cli-e2e/fixtures/scenarios/gen-types-includes-debug-output-with-debug/interactions.json index 07cda5a499..513d2118ed 100644 --- a/apps/cli-e2e/fixtures/scenarios/gen-types-includes-debug-output-with-debug/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/gen-types-includes-debug-output-with-debug/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/types/typescript", + "path": "/v1/projects/replayfixtureprojref/types/typescript", "query": { "included_schemas": "public" }, diff --git a/apps/cli-e2e/fixtures/scenarios/link-links-project-successfully/interactions.json b/apps/cli-e2e/fixtures/scenarios/link-links-project-successfully/interactions.json index ba7f580369..f6f94156a5 100644 --- a/apps/cli-e2e/fixtures/scenarios/link-links-project-successfully/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/link-links-project-successfully/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -18,8 +18,8 @@ "content-type": "application/json; charset=utf-8" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", "name": "cli-e2e-test", "organization_id": "test-org", "organization_slug": "test-org", @@ -27,7 +27,7 @@ "created_at": "2000-01-01T00:00:00Z", "status": "ACTIVE_HEALTHY", "database": { - "host": "__PROJECT_REF__.supabase.co", + "host": "replayfixtureprojref.supabase.co", "postgres_engine": "postgres", "release_channel": "ga", "version": "15.6.1.143" @@ -38,7 +38,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/api-keys", + "path": "/v1/projects/replayfixtureprojref/api-keys", "query": { "reveal": "true" }, diff --git a/apps/cli-e2e/fixtures/scenarios/link-links-when-only-supabase-project-id-is-set-in-non-tty/interactions.json b/apps/cli-e2e/fixtures/scenarios/link-links-when-only-supabase-project-id-is-set-in-non-tty/interactions.json index b279ebcbf0..c6976da175 100644 --- a/apps/cli-e2e/fixtures/scenarios/link-links-when-only-supabase-project-id-is-set-in-non-tty/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/link-links-when-only-supabase-project-id-is-set-in-non-tty/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -22,15 +22,15 @@ "x-ratelimit-reset": "24" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" @@ -42,7 +42,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/api-keys", + "path": "/v1/projects/replayfixtureprojref/api-keys", "query": { "reveal": "true" }, @@ -114,7 +114,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -139,7 +139,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions", + "path": "/v1/projects/replayfixtureprojref/network-restrictions", "query": {}, "headers": { "accept-encoding": "gzip", @@ -172,7 +172,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/pooler", + "path": "/v1/projects/replayfixtureprojref/config/database/pooler", "query": {}, "headers": { "accept-encoding": "gzip", @@ -193,18 +193,18 @@ }, "body": [ { - "identifier": "__PROJECT_REF__", + "identifier": "replayfixtureprojref", "database_type": "PRIMARY", - "db_user": "postgres.__PROJECT_REF__", + "db_user": "postgres.replayfixtureprojref", "db_host": "aws-0-us-east-1.pooler.supabase.green", "db_port": 6543, "db_name": "postgres", - "connection_string": "postgresql://postgres.__PROJECT_REF__:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.green:6543/postgres", + "connection_string": "postgresql://postgres.replayfixtureprojref:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.green:6543/postgres", "pool_mode": "transaction", "default_pool_size": null, "max_client_conn": null, "is_using_scram_auth": true, - "connectionString": "postgresql://postgres.__PROJECT_REF__:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.green:6543/postgres" + "connectionString": "postgresql://postgres.replayfixtureprojref:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.green:6543/postgres" } ] } @@ -212,7 +212,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/postgrest", + "path": "/v1/projects/replayfixtureprojref/postgrest", "query": {}, "headers": { "accept-encoding": "gzip", @@ -244,7 +244,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/storage", + "path": "/v1/projects/replayfixtureprojref/config/storage", "query": {}, "headers": { "accept-encoding": "gzip", @@ -303,7 +303,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth", + "path": "/v1/projects/replayfixtureprojref/config/auth", "query": {}, "headers": { "accept-encoding": "gzip", @@ -355,6 +355,7 @@ "hook_before_user_created_secrets": null, "nimbus_oauth_client_id": null, "nimbus_oauth_client_secret": null, + "nimbus_oauth_email_optional": null, "external_anonymous_users_enabled": false, "external_apple_additional_client_ids": null, "external_apple_client_id": null, diff --git a/apps/cli-e2e/fixtures/scenarios/link-skip-pooler-uses-direct-connection/interactions.json b/apps/cli-e2e/fixtures/scenarios/link-skip-pooler-uses-direct-connection/interactions.json index ba7f580369..f6f94156a5 100644 --- a/apps/cli-e2e/fixtures/scenarios/link-skip-pooler-uses-direct-connection/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/link-skip-pooler-uses-direct-connection/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -18,8 +18,8 @@ "content-type": "application/json; charset=utf-8" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", "name": "cli-e2e-test", "organization_id": "test-org", "organization_slug": "test-org", @@ -27,7 +27,7 @@ "created_at": "2000-01-01T00:00:00Z", "status": "ACTIVE_HEALTHY", "database": { - "host": "__PROJECT_REF__.supabase.co", + "host": "replayfixtureprojref.supabase.co", "postgres_engine": "postgres", "release_channel": "ga", "version": "15.6.1.143" @@ -38,7 +38,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/api-keys", + "path": "/v1/projects/replayfixtureprojref/api-keys", "query": { "reveal": "true" }, diff --git a/apps/cli-e2e/fixtures/scenarios/network-bans-get-includes-debug-output-with-debug/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-bans-get-includes-debug-output-with-debug/interactions.json index e8905394e8..35aa107156 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-bans-get-includes-debug-output-with-debug/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-bans-get-includes-debug-output-with-debug/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/network-bans/retrieve", + "path": "/v1/projects/replayfixtureprojref/network-bans/retrieve", "query": {}, "headers": { "accept-encoding": "gzip", @@ -30,7 +30,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -50,15 +50,15 @@ "x-ratelimit-reset": "8" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/network-bans-get-renders-fixture-data-in-output/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-bans-get-renders-fixture-data-in-output/interactions.json index 41017bfbfa..03aa7ad50a 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-bans-get-renders-fixture-data-in-output/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-bans-get-renders-fixture-data-in-output/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/network-bans/retrieve", + "path": "/v1/projects/replayfixtureprojref/network-bans/retrieve", "query": {}, "headers": { "accept-encoding": "gzip", @@ -30,7 +30,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -50,15 +50,15 @@ "x-ratelimit-reset": "10" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/network-bans-get-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-bans-get-returns-json-output-with-output-json/interactions.json index 633c051b8f..86b4d71aa5 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-bans-get-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-bans-get-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/network-bans/retrieve", + "path": "/v1/projects/replayfixtureprojref/network-bans/retrieve", "query": {}, "headers": { "accept-encoding": "gzip", @@ -30,7 +30,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -50,15 +50,15 @@ "x-ratelimit-reset": "9" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/network-bans-remove-exits-non-zero-with-invalid-ip-address/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-bans-remove-exits-non-zero-with-invalid-ip-address/interactions.json index 6dcedb0209..9e641fefb7 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-bans-remove-exits-non-zero-with-invalid-ip-address/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-bans-remove-exits-non-zero-with-invalid-ip-address/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/network-bans-remove-removes-ip-from-ban-list/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-bans-remove-removes-ip-from-ban-list/interactions.json index 864b9ca700..d7a1acf023 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-bans-remove-removes-ip-from-ban-list/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-bans-remove-removes-ip-from-ban-list/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/network-bans", + "path": "/v1/projects/replayfixtureprojref/network-bans", "query": {}, "headers": { "accept-encoding": "gzip", @@ -31,7 +31,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -51,15 +51,15 @@ "x-ratelimit-reset": "5" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/network-bans-remove-removes-multiple-ips-from-ban-list/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-bans-remove-removes-multiple-ips-from-ban-list/interactions.json index e10100c7a8..9629d333dd 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-bans-remove-removes-multiple-ips-from-ban-list/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-bans-remove-removes-multiple-ips-from-ban-list/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/network-bans", + "path": "/v1/projects/replayfixtureprojref/network-bans", "query": {}, "headers": { "accept-encoding": "gzip", @@ -31,7 +31,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -51,15 +51,15 @@ "x-ratelimit-reset": "4" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/network-restrictions-get-renders-fixture-data-in-output/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-restrictions-get-renders-fixture-data-in-output/interactions.json index 40e570e6a1..cfb53d4583 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-restrictions-get-renders-fixture-data-in-output/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-restrictions-get-renders-fixture-data-in-output/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions", + "path": "/v1/projects/replayfixtureprojref/network-restrictions", "query": {}, "headers": { "accept-encoding": "gzip", @@ -34,7 +34,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -54,15 +54,15 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/network-restrictions-get-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-restrictions-get-returns-json-output-with-output-json/interactions.json index 58a0472b91..82fe595445 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-restrictions-get-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-restrictions-get-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions", + "path": "/v1/projects/replayfixtureprojref/network-restrictions", "query": {}, "headers": { "accept-encoding": "gzip", @@ -34,7 +34,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -54,15 +54,15 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-appends-to-existing-restrictions/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-appends-to-existing-restrictions/interactions.json index dce5e8df40..482e20f8c6 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-appends-to-existing-restrictions/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-appends-to-existing-restrictions/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "PATCH", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions", + "path": "/v1/projects/replayfixtureprojref/network-restrictions", "query": {}, "headers": { "accept-encoding": "gzip", @@ -58,7 +58,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -78,15 +78,15 @@ "x-ratelimit-reset": "49" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-bypasses-cidr-validation-checks/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-bypasses-cidr-validation-checks/interactions.json index a467ac4948..53f36a1563 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-bypasses-cidr-validation-checks/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-bypasses-cidr-validation-checks/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions/apply", + "path": "/v1/projects/replayfixtureprojref/network-restrictions/apply", "query": {}, "headers": { "accept-encoding": "gzip", @@ -39,7 +39,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -59,15 +59,15 @@ "x-ratelimit-reset": "45" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-exits-non-zero-with-invalid-cidr-format/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-exits-non-zero-with-invalid-cidr-format/interactions.json index 8acde92835..08b01f8470 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-exits-non-zero-with-invalid-cidr-format/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-exits-non-zero-with-invalid-cidr-format/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-sets-cidr-allowlist/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-sets-cidr-allowlist/interactions.json index cc9be8cd40..4540b3d181 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-sets-cidr-allowlist/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-sets-cidr-allowlist/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions/apply", + "path": "/v1/projects/replayfixtureprojref/network-restrictions/apply", "query": {}, "headers": { "accept-encoding": "gzip", @@ -39,7 +39,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -59,15 +59,15 @@ "x-ratelimit-reset": "54" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/orgs-create-creates-organization-with-name/interactions.json b/apps/cli-e2e/fixtures/scenarios/orgs-create-creates-organization-with-name/interactions.json index 97b40f9e21..c35add8241 100644 --- a/apps/cli-e2e/fixtures/scenarios/orgs-create-creates-organization-with-name/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/orgs-create-creates-organization-with-name/interactions.json @@ -21,8 +21,8 @@ "content-type": "application/json; charset=utf-8" }, "body": { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "My Test Org" } } diff --git a/apps/cli-e2e/fixtures/scenarios/orgs-list-includes-debug-output-with-debug/interactions.json b/apps/cli-e2e/fixtures/scenarios/orgs-list-includes-debug-output-with-debug/interactions.json index 242c6ca234..1f359a2bd8 100644 --- a/apps/cli-e2e/fixtures/scenarios/orgs-list-includes-debug-output-with-debug/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/orgs-list-includes-debug-output-with-debug/interactions.json @@ -23,13 +23,13 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Org" }, { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "CLIPort-TEST" } ] diff --git a/apps/cli-e2e/fixtures/scenarios/orgs-list-renders-org-data/interactions.json b/apps/cli-e2e/fixtures/scenarios/orgs-list-renders-org-data/interactions.json index e497a526a5..9fa76f7eab 100644 --- a/apps/cli-e2e/fixtures/scenarios/orgs-list-renders-org-data/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/orgs-list-renders-org-data/interactions.json @@ -23,13 +23,13 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Org" }, { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "CLIPort-TEST" } ] diff --git a/apps/cli-e2e/fixtures/scenarios/orgs-list-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/orgs-list-returns-json-output-with-output-json/interactions.json index 3c53dab9ab..31910e9c4d 100644 --- a/apps/cli-e2e/fixtures/scenarios/orgs-list-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/orgs-list-returns-json-output-with-output-json/interactions.json @@ -23,13 +23,13 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Org" }, { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "CLIPort-TEST" } ] diff --git a/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-no-restart-removes-config-without-restarting-postgres/interactions.json b/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-no-restart-removes-config-without-restarting-postgres/interactions.json index 821e27abda..4914053cc2 100644 --- a/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-no-restart-removes-config-without-restarting-postgres/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-no-restart-removes-config-without-restarting-postgres/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -56,7 +56,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -76,15 +76,15 @@ "x-ratelimit-reset": "30" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-removes-config-override/interactions.json b/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-removes-config-override/interactions.json index 9225167c2b..9a60b499d6 100644 --- a/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-removes-config-override/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-removes-config-override/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -56,7 +56,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -76,15 +76,15 @@ "x-ratelimit-reset": "38" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-removes-multiple-config-keys/interactions.json b/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-removes-multiple-config-keys/interactions.json index 07a7923cf3..f701e2faab 100644 --- a/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-removes-multiple-config-keys/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-removes-multiple-config-keys/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -54,7 +54,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -74,15 +74,15 @@ "x-ratelimit-reset": "33" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/postgres-config-get-debug-shows-http-trace/interactions.json b/apps/cli-e2e/fixtures/scenarios/postgres-config-get-debug-shows-http-trace/interactions.json index 900dec87ec..abc329c512 100644 --- a/apps/cli-e2e/fixtures/scenarios/postgres-config-get-debug-shows-http-trace/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/postgres-config-get-debug-shows-http-trace/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "58" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/postgres-config-get-renders-config-overrides/interactions.json b/apps/cli-e2e/fixtures/scenarios/postgres-config-get-renders-config-overrides/interactions.json index 92e5ac77c7..75381aed00 100644 --- a/apps/cli-e2e/fixtures/scenarios/postgres-config-get-renders-config-overrides/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/postgres-config-get-renders-config-overrides/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/postgres-config-get-returns-config-overrides-as-json-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/postgres-config-get-returns-config-overrides-as-json-with-output-json/interactions.json index dc5b651387..5d0e6357c0 100644 --- a/apps/cli-e2e/fixtures/scenarios/postgres-config-get-returns-config-overrides-as-json-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/postgres-config-get-returns-config-overrides-as-json-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/postgres-config-update-no-restart-applies-config-without-restarting-postgres/interactions.json b/apps/cli-e2e/fixtures/scenarios/postgres-config-update-no-restart-applies-config-without-restarting-postgres/interactions.json index 12ec1ea4be..678c834a5d 100644 --- a/apps/cli-e2e/fixtures/scenarios/postgres-config-update-no-restart-applies-config-without-restarting-postgres/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/postgres-config-update-no-restart-applies-config-without-restarting-postgres/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -61,7 +61,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -81,15 +81,15 @@ "x-ratelimit-reset": "42" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/postgres-config-update-replace-existing-overrides-replaces-all-overrides/interactions.json b/apps/cli-e2e/fixtures/scenarios/postgres-config-update-replace-existing-overrides-replaces-all-overrides/interactions.json index 17d60e2835..4b7fc25fc7 100644 --- a/apps/cli-e2e/fixtures/scenarios/postgres-config-update-replace-existing-overrides-replaces-all-overrides/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/postgres-config-update-replace-existing-overrides-replaces-all-overrides/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -33,7 +33,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -53,15 +53,15 @@ "x-ratelimit-reset": "45" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/postgres-config-update-sets-multiple-config-overrides/interactions.json b/apps/cli-e2e/fixtures/scenarios/postgres-config-update-sets-multiple-config-overrides/interactions.json index aef13964cb..a6a8783096 100644 --- a/apps/cli-e2e/fixtures/scenarios/postgres-config-update-sets-multiple-config-overrides/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/postgres-config-update-sets-multiple-config-overrides/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -62,7 +62,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -82,15 +82,15 @@ "x-ratelimit-reset": "49" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/postgres-config-update-sets-single-config-override/interactions.json b/apps/cli-e2e/fixtures/scenarios/postgres-config-update-sets-single-config-override/interactions.json index cfc576b4cf..4f4322a50d 100644 --- a/apps/cli-e2e/fixtures/scenarios/postgres-config-update-sets-single-config-override/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/postgres-config-update-sets-single-config-override/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -58,7 +58,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -78,15 +78,15 @@ "x-ratelimit-reset": "54" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/projects-api-keys-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/projects-api-keys-returns-json-output-with-output-json/interactions.json index 67877d5c1a..5a5719e8f7 100644 --- a/apps/cli-e2e/fixtures/scenarios/projects-api-keys-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/projects-api-keys-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/api-keys", + "path": "/v1/projects/replayfixtureprojref/api-keys", "query": {}, "headers": { "accept-encoding": "gzip", @@ -72,7 +72,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -92,15 +92,15 @@ "x-ratelimit-reset": "11" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/projects-api-keys-shows-default-and-anon-keys/interactions.json b/apps/cli-e2e/fixtures/scenarios/projects-api-keys-shows-default-and-anon-keys/interactions.json index 99b3e6b9a4..c7acb2ada2 100644 --- a/apps/cli-e2e/fixtures/scenarios/projects-api-keys-shows-default-and-anon-keys/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/projects-api-keys-shows-default-and-anon-keys/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/api-keys", + "path": "/v1/projects/replayfixtureprojref/api-keys", "query": {}, "headers": { "accept-encoding": "gzip", @@ -72,7 +72,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -92,15 +92,15 @@ "x-ratelimit-reset": "13" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/projects-create-creates-project-with-required-flags/interactions.json b/apps/cli-e2e/fixtures/scenarios/projects-create-creates-project-with-required-flags/interactions.json index f1f64d4316..7b718e4b03 100644 --- a/apps/cli-e2e/fixtures/scenarios/projects-create-creates-project-with-required-flags/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/projects-create-creates-project-with-required-flags/interactions.json @@ -16,7 +16,7 @@ "db_pass": "password123", "desired_instance_size": "micro", "name": "my-project", - "organization_slug": "__PROJECT_REF__", + "organization_slug": "replayfixtureprojref", "region": "us-east-1" } }, @@ -30,10 +30,10 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "my-project", "region": "us-east-1", "created_at": "2000-01-01T00:00:00Z", @@ -44,7 +44,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -64,15 +64,15 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "my-project", "region": "us-east-1", "status": "COMING_UP", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/projects-delete-deletes-project-with-yes-flag/interactions.json b/apps/cli-e2e/fixtures/scenarios/projects-delete-deletes-project-with-yes-flag/interactions.json index e9dc83bf97..dfebb27601 100644 --- a/apps/cli-e2e/fixtures/scenarios/projects-delete-deletes-project-with-yes-flag/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/projects-delete-deletes-project-with-yes-flag/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -20,7 +20,7 @@ "body": { "id": 1, "name": "cli-e2e-test", - "ref": "__PROJECT_REF__" + "ref": "replayfixtureprojref" } } } diff --git a/apps/cli-e2e/fixtures/scenarios/projects-list-includes-debug-output-with-debug/interactions.json b/apps/cli-e2e/fixtures/scenarios/projects-list-includes-debug-output-with-debug/interactions.json index a331688dde..eded40a8e4 100644 --- a/apps/cli-e2e/fixtures/scenarios/projects-list-includes-debug-output-with-debug/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/projects-list-includes-debug-output-with-debug/interactions.json @@ -23,15 +23,15 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Project", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.084", "postgres_engine": "17", "release_channel": "ga" @@ -39,15 +39,15 @@ "created_at": "2000-01-01T00:00:00Z" }, { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" @@ -60,7 +60,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -80,15 +80,15 @@ "x-ratelimit-reset": "15" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/projects-list-renders-project-list/interactions.json b/apps/cli-e2e/fixtures/scenarios/projects-list-renders-project-list/interactions.json index 0f1dd8a924..3adca9f7a2 100644 --- a/apps/cli-e2e/fixtures/scenarios/projects-list-renders-project-list/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/projects-list-renders-project-list/interactions.json @@ -23,15 +23,15 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Project", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.084", "postgres_engine": "17", "release_channel": "ga" @@ -39,15 +39,15 @@ "created_at": "2000-01-01T00:00:00Z" }, { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" @@ -60,7 +60,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -80,15 +80,15 @@ "x-ratelimit-reset": "17" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/projects-list-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/projects-list-returns-json-output-with-output-json/interactions.json index 97abd08a53..2a2bc47579 100644 --- a/apps/cli-e2e/fixtures/scenarios/projects-list-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/projects-list-returns-json-output-with-output-json/interactions.json @@ -23,15 +23,15 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Project", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.084", "postgres_engine": "17", "release_channel": "ga" @@ -39,15 +39,15 @@ "created_at": "2000-01-01T00:00:00Z" }, { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" @@ -60,7 +60,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -80,15 +80,15 @@ "x-ratelimit-reset": "16" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/secrets-list-includes-debug-output-with-debug/interactions.json b/apps/cli-e2e/fixtures/scenarios/secrets-list-includes-debug-output-with-debug/interactions.json index 5fadaa09e3..f2f535dc35 100644 --- a/apps/cli-e2e/fixtures/scenarios/secrets-list-includes-debug-output-with-debug/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/secrets-list-includes-debug-output-with-debug/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "58" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/secrets-list-renders-fixture-data-in-output/interactions.json b/apps/cli-e2e/fixtures/scenarios/secrets-list-renders-fixture-data-in-output/interactions.json index 881b16c20e..43e0e51f84 100644 --- a/apps/cli-e2e/fixtures/scenarios/secrets-list-renders-fixture-data-in-output/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/secrets-list-renders-fixture-data-in-output/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/secrets-list-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/secrets-list-returns-json-output-with-output-json/interactions.json index e47bddc8fd..2f13f43970 100644 --- a/apps/cli-e2e/fixtures/scenarios/secrets-list-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/secrets-list-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "59" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/secrets-set-exits-non-zero-when-env-file-not-found/interactions.json b/apps/cli-e2e/fixtures/scenarios/secrets-set-exits-non-zero-when-env-file-not-found/interactions.json index b090bbeaed..ce2ca6ec9f 100644 --- a/apps/cli-e2e/fixtures/scenarios/secrets-set-exits-non-zero-when-env-file-not-found/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/secrets-set-exits-non-zero-when-env-file-not-found/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-a-single-secret/interactions.json b/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-a-single-secret/interactions.json index 5372e19be3..1492ce7513 100644 --- a/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-a-single-secret/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-a-single-secret/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", @@ -33,7 +33,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -53,15 +53,15 @@ "x-ratelimit-reset": "56" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-multiple-secrets/interactions.json b/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-multiple-secrets/interactions.json index 70143d839b..6e54c07f13 100644 --- a/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-multiple-secrets/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-multiple-secrets/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", @@ -37,7 +37,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -57,15 +57,15 @@ "x-ratelimit-reset": "55" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-secrets-from-env-file/interactions.json b/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-secrets-from-env-file/interactions.json index 55ab3fdfca..c0392f0ef3 100644 --- a/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-secrets-from-env-file/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-secrets-from-env-file/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", @@ -33,7 +33,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -53,15 +53,15 @@ "x-ratelimit-reset": "54" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/secrets-unset-removes-a-secret/interactions.json b/apps/cli-e2e/fixtures/scenarios/secrets-unset-removes-a-secret/interactions.json index 911375fde0..b895883b4e 100644 --- a/apps/cli-e2e/fixtures/scenarios/secrets-unset-removes-a-secret/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/secrets-unset-removes-a-secret/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", @@ -28,7 +28,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -48,15 +48,15 @@ "x-ratelimit-reset": "51" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/secrets-unset-removes-multiple-secrets/interactions.json b/apps/cli-e2e/fixtures/scenarios/secrets-unset-removes-multiple-secrets/interactions.json index d92197e920..42c3f0ab43 100644 --- a/apps/cli-e2e/fixtures/scenarios/secrets-unset-removes-multiple-secrets/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/secrets-unset-removes-multiple-secrets/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", @@ -37,7 +37,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -57,15 +57,15 @@ "x-ratelimit-reset": "50" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" @@ -77,7 +77,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/seed-buckets-creates-buckets-defined-in-config/interactions.json b/apps/cli-e2e/fixtures/scenarios/seed-buckets-creates-buckets-defined-in-config/interactions.json index 78125524cf..eab883b62a 100644 --- a/apps/cli-e2e/fixtures/scenarios/seed-buckets-creates-buckets-defined-in-config/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/seed-buckets-creates-buckets-defined-in-config/interactions.json @@ -20,7 +20,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, @@ -54,7 +54,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, @@ -86,7 +86,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/scenarios/services-lists-known-service-images/interactions.json b/apps/cli-e2e/fixtures/scenarios/services-lists-known-service-images/interactions.json index 80579ad512..ad952e4020 100644 --- a/apps/cli-e2e/fixtures/scenarios/services-lists-known-service-images/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/services-lists-known-service-images/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/api-keys", + "path": "/v1/projects/replayfixtureprojref/api-keys", "query": { "reveal": "true" }, @@ -74,7 +74,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -94,15 +94,15 @@ "x-ratelimit-reset": "34" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" @@ -114,7 +114,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -134,15 +134,15 @@ "x-ratelimit-reset": "33" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/snippets-download-prints-sql-content-to-stdout/interactions.json b/apps/cli-e2e/fixtures/scenarios/snippets-download-prints-sql-content-to-stdout/interactions.json index 4fbf1b91e1..d95878e319 100644 --- a/apps/cli-e2e/fixtures/scenarios/snippets-download-prints-sql-content-to-stdout/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/snippets-download-prints-sql-content-to-stdout/interactions.json @@ -29,6 +29,7 @@ "favorite": false, "id": "__UUID__", "inserted_at": "2000-01-01T00:00:00Z", + "description": null, "name": "test-snippet", "owner": { "id": 1, @@ -51,7 +52,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -71,15 +72,15 @@ "x-ratelimit-reset": "45" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/snippets-list-debug-shows-http-trace/interactions.json b/apps/cli-e2e/fixtures/scenarios/snippets-list-debug-shows-http-trace/interactions.json index bfd8a503d6..6770f4fe70 100644 --- a/apps/cli-e2e/fixtures/scenarios/snippets-list-debug-shows-http-trace/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/snippets-list-debug-shows-http-trace/interactions.json @@ -4,7 +4,7 @@ "method": "GET", "path": "/v1/snippets", "query": { - "project_ref": "__PROJECT_REF__" + "project_ref": "replayfixtureprojref" }, "headers": { "accept-encoding": "gzip", @@ -31,7 +31,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -51,15 +51,15 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/snippets-list-renders-snippet-table-with-id-and-name-columns/interactions.json b/apps/cli-e2e/fixtures/scenarios/snippets-list-renders-snippet-table-with-id-and-name-columns/interactions.json index 94fece5304..a882be44b9 100644 --- a/apps/cli-e2e/fixtures/scenarios/snippets-list-renders-snippet-table-with-id-and-name-columns/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/snippets-list-renders-snippet-table-with-id-and-name-columns/interactions.json @@ -4,7 +4,7 @@ "method": "GET", "path": "/v1/snippets", "query": { - "project_ref": "__PROJECT_REF__" + "project_ref": "replayfixtureprojref" }, "headers": { "accept-encoding": "gzip", @@ -31,7 +31,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -51,15 +51,15 @@ "x-ratelimit-reset": "5" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/snippets-list-returns-snippet-list-as-json-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/snippets-list-returns-snippet-list-as-json-with-output-json/interactions.json index 335ed811d4..15666a2ee4 100644 --- a/apps/cli-e2e/fixtures/scenarios/snippets-list-returns-snippet-list-as-json-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/snippets-list-returns-snippet-list-as-json-with-output-json/interactions.json @@ -4,7 +4,7 @@ "method": "GET", "path": "/v1/snippets", "query": { - "project_ref": "__PROJECT_REF__" + "project_ref": "replayfixtureprojref" }, "headers": { "accept-encoding": "gzip", @@ -31,7 +31,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -51,15 +51,15 @@ "x-ratelimit-reset": "2" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-get-renders-fixture-data-in-output/interactions.json b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-get-renders-fixture-data-in-output/interactions.json index 830ce723de..df9435b0cb 100644 --- a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-get-renders-fixture-data-in-output/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-get-renders-fixture-data-in-output/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/ssl-enforcement", + "path": "/v1/projects/replayfixtureprojref/ssl-enforcement", "query": {}, "headers": { "accept-encoding": "gzip", @@ -32,7 +32,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -52,15 +52,15 @@ "x-ratelimit-reset": "44" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-get-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-get-returns-json-output-with-output-json/interactions.json index 561cd24a62..a0c952992c 100644 --- a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-get-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-get-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/ssl-enforcement", + "path": "/v1/projects/replayfixtureprojref/ssl-enforcement", "query": {}, "headers": { "accept-encoding": "gzip", @@ -32,7 +32,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -52,15 +52,15 @@ "x-ratelimit-reset": "42" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-disables-ssl-enforcement/interactions.json b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-disables-ssl-enforcement/interactions.json index 54c63a6650..2c29e6700d 100644 --- a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-disables-ssl-enforcement/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-disables-ssl-enforcement/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/ssl-enforcement", + "path": "/v1/projects/replayfixtureprojref/ssl-enforcement", "query": {}, "headers": { "accept-encoding": "gzip", @@ -38,7 +38,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -58,15 +58,15 @@ "x-ratelimit-reset": "29" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-enables-ssl-enforcement/interactions.json b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-enables-ssl-enforcement/interactions.json index f729e171ec..a982b5fc89 100644 --- a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-enables-ssl-enforcement/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-enables-ssl-enforcement/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/ssl-enforcement", + "path": "/v1/projects/replayfixtureprojref/ssl-enforcement", "query": {}, "headers": { "accept-encoding": "gzip", @@ -38,7 +38,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -58,15 +58,15 @@ "x-ratelimit-reset": "36" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-exits-non-zero-with-mutually-exclusive-flags/interactions.json b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-exits-non-zero-with-mutually-exclusive-flags/interactions.json index 23341d1d3a..3d628fbee6 100644 --- a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-exits-non-zero-with-mutually-exclusive-flags/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-exits-non-zero-with-mutually-exclusive-flags/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-exits-non-zero-with-no-flags-provided/interactions.json b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-exits-non-zero-with-no-flags-provided/interactions.json index 287698b597..9e692194c6 100644 --- a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-exits-non-zero-with-no-flags-provided/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-exits-non-zero-with-no-flags-provided/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-add-adds-saml-provider-via-metadata-file/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-add-adds-saml-provider-via-metadata-file/interactions.json index feeafc375e..3099f4734a 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-add-adds-saml-provider-via-metadata-file/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-add-adds-saml-provider-via-metadata-file/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers", "query": {}, "headers": { "accept-encoding": "gzip", @@ -48,7 +48,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -68,15 +68,15 @@ "x-ratelimit-reset": "41" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-with-both-metadata-url-and-metadata-file/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-with-both-metadata-url-and-metadata-file/interactions.json index b98259a2bd..03d50e5cb5 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-with-both-metadata-url-and-metadata-file/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-with-both-metadata-url-and-metadata-file/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-with-unreachable-metadata-url/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-with-unreachable-metadata-url/interactions.json index 8589df9bc9..ae09586a2e 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-with-unreachable-metadata-url/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-with-unreachable-metadata-url/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-without-type/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-without-type/interactions.json index aa29b5e589..e6024ce918 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-without-type/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-without-type/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-info-renders-fixture-data-in-output/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-info-renders-fixture-data-in-output/interactions.json index 818805015d..4121787574 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-info-renders-fixture-data-in-output/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-info-renders-fixture-data-in-output/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -22,15 +22,15 @@ "x-ratelimit-reset": "44" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/sso-info-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-info-returns-json-output-with-output-json/interactions.json index 97effe42c4..e1f7a613e5 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-info-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-info-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -22,15 +22,15 @@ "x-ratelimit-reset": "43" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/sso-list-includes-debug-output-with-debug/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-list-includes-debug-output-with-debug/interactions.json index 52b1f71229..567dfe9b5c 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-list-includes-debug-output-with-debug/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-list-includes-debug-output-with-debug/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "44" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/sso-list-renders-fixture-data-in-output/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-list-renders-fixture-data-in-output/interactions.json index 84a58914fc..b4c9d3b144 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-list-renders-fixture-data-in-output/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-list-renders-fixture-data-in-output/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "47" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/sso-list-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-list-returns-json-output-with-output-json/interactions.json index 1b3ffb11c3..28fa88026b 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-list-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-list-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "46" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/sso-remove-removes-a-provider/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-remove-removes-a-provider/interactions.json index 9aaf05e057..90f7f2d4f5 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-remove-removes-a-provider/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-remove-removes-a-provider/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-show-renders-fixture-data-in-output/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-show-renders-fixture-data-in-output/interactions.json index a62ab85658..ed656af6cd 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-show-renders-fixture-data-in-output/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-show-renders-fixture-data-in-output/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-show-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-show-returns-json-output-with-output-json/interactions.json index a62ab85658..ed656af6cd 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-show-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-show-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-show-shows-raw-saml-metadata-xml-with-metadata/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-show-shows-raw-saml-metadata-xml-with-metadata/interactions.json index 3a4c43c3e0..fc74a58403 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-show-shows-raw-saml-metadata-xml-with-metadata/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-show-shows-raw-saml-metadata-xml-with-metadata/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-update-appends-domain-with-add-domains/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-update-appends-domain-with-add-domains/interactions.json index 69024725a6..f8bb4939ac 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-update-appends-domain-with-add-domains/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-update-appends-domain-with-add-domains/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,7 +47,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-update-exits-non-zero-with-domains-and-add-domains/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-update-exits-non-zero-with-domains-and-add-domains/interactions.json index e33a201bee..5ad40ab113 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-update-exits-non-zero-with-domains-and-add-domains/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-update-exits-non-zero-with-domains-and-add-domains/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-update-removes-domain-with-remove-domains/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-update-removes-domain-with-remove-domains/interactions.json index 36f75d5545..7fe3a1e4fd 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-update-removes-domain-with-remove-domains/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-update-removes-domain-with-remove-domains/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,7 +47,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-update-replaces-domains-with-domains/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-update-replaces-domains-with-domains/interactions.json index 48bdcbd09e..3dc51e5982 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-update-replaces-domains-with-domains/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-update-replaces-domains-with-domains/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,7 +47,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-update-updates-metadata-via-metadata-file/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-update-updates-metadata-via-metadata-file/interactions.json index e0f4d584fd..0f0e557f8e 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-update-updates-metadata-via-metadata-file/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-update-updates-metadata-via-metadata-file/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,7 +47,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/storage-cp-downloads-file-from-storage/interactions.json b/apps/cli-e2e/fixtures/scenarios/storage-cp-downloads-file-from-storage/interactions.json index 7ebe30ba16..fc030d1e06 100644 --- a/apps/cli-e2e/fixtures/scenarios/storage-cp-downloads-file-from-storage/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/storage-cp-downloads-file-from-storage/interactions.json @@ -23,7 +23,7 @@ "last-modified": "Mon, 27 Apr 2026 10:30:57 GMT", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-robots-tag": "none", "x-smart-cdn": "true" diff --git a/apps/cli-e2e/fixtures/scenarios/storage-cp-passes-cache-control-header-on-upload/interactions.json b/apps/cli-e2e/fixtures/scenarios/storage-cp-passes-cache-control-header-on-upload/interactions.json index 230c6077cd..858845ecb8 100644 --- a/apps/cli-e2e/fixtures/scenarios/storage-cp-passes-cache-control-header-on-upload/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/storage-cp-passes-cache-control-header-on-upload/interactions.json @@ -23,7 +23,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { diff --git a/apps/cli-e2e/fixtures/scenarios/storage-cp-passes-content-type-header-on-upload/interactions.json b/apps/cli-e2e/fixtures/scenarios/storage-cp-passes-content-type-header-on-upload/interactions.json index 3285ccf1a7..6b1283552a 100644 --- a/apps/cli-e2e/fixtures/scenarios/storage-cp-passes-content-type-header-on-upload/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/storage-cp-passes-content-type-header-on-upload/interactions.json @@ -23,7 +23,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { diff --git a/apps/cli-e2e/fixtures/scenarios/storage-cp-uploads-local-file-to-storage/interactions.json b/apps/cli-e2e/fixtures/scenarios/storage-cp-uploads-local-file-to-storage/interactions.json index c5cffe3abf..c6dac8a056 100644 --- a/apps/cli-e2e/fixtures/scenarios/storage-cp-uploads-local-file-to-storage/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/storage-cp-uploads-local-file-to-storage/interactions.json @@ -23,7 +23,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { diff --git a/apps/cli-e2e/fixtures/scenarios/storage-ls-lists-objects-in-bucket/interactions.json b/apps/cli-e2e/fixtures/scenarios/storage-ls-lists-objects-in-bucket/interactions.json index e7a2cfebb5..514b26f72f 100644 --- a/apps/cli-e2e/fixtures/scenarios/storage-ls-lists-objects-in-bucket/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/storage-ls-lists-objects-in-bucket/interactions.json @@ -25,7 +25,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/scenarios/storage-ls-lists-objects-recursively/interactions.json b/apps/cli-e2e/fixtures/scenarios/storage-ls-lists-objects-recursively/interactions.json index e7a2cfebb5..514b26f72f 100644 --- a/apps/cli-e2e/fixtures/scenarios/storage-ls-lists-objects-recursively/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/storage-ls-lists-objects-recursively/interactions.json @@ -25,7 +25,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/scenarios/storage-mv-moves-file-within-bucket/interactions.json b/apps/cli-e2e/fixtures/scenarios/storage-mv-moves-file-within-bucket/interactions.json index 4d33f56cdb..0c2a02322a 100644 --- a/apps/cli-e2e/fixtures/scenarios/storage-mv-moves-file-within-bucket/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/storage-mv-moves-file-within-bucket/interactions.json @@ -23,7 +23,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { @@ -59,7 +59,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/scenarios/storage-rm-removes-a-file-from-storage/interactions.json b/apps/cli-e2e/fixtures/scenarios/storage-rm-removes-a-file-from-storage/interactions.json index 033a1b257f..299fc71e8d 100644 --- a/apps/cli-e2e/fixtures/scenarios/storage-rm-removes-a-file-from-storage/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/storage-rm-removes-a-file-from-storage/interactions.json @@ -23,7 +23,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { @@ -57,7 +57,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/scenarios/storage-rm-removes-multiple-files/interactions.json b/apps/cli-e2e/fixtures/scenarios/storage-rm-removes-multiple-files/interactions.json index 38d3618105..70b520d648 100644 --- a/apps/cli-e2e/fixtures/scenarios/storage-rm-removes-multiple-files/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/storage-rm-removes-multiple-files/interactions.json @@ -23,7 +23,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { @@ -56,7 +56,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { @@ -90,7 +90,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-activate-activates-desired-vanity-subdomain/interactions.json b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-activate-activates-desired-vanity-subdomain/interactions.json index f506480032..925385f399 100644 --- a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-activate-activates-desired-vanity-subdomain/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-activate-activates-desired-vanity-subdomain/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain/activate", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain/activate", "query": {}, "headers": { "accept-encoding": "gzip", @@ -33,7 +33,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -53,15 +53,15 @@ "x-ratelimit-reset": "19" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-exits-non-zero-without-desired-subdomain-flag/interactions.json b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-exits-non-zero-without-desired-subdomain-flag/interactions.json index b090bbeaed..ce2ca6ec9f 100644 --- a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-exits-non-zero-without-desired-subdomain-flag/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-exits-non-zero-without-desired-subdomain-flag/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-reports-availability-for-desired-subdomain/interactions.json b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-reports-availability-for-desired-subdomain/interactions.json index 31740f1e3c..d8a047d25e 100644 --- a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-reports-availability-for-desired-subdomain/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-reports-availability-for-desired-subdomain/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain/check-availability", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain/check-availability", "query": {}, "headers": { "accept-encoding": "gzip", @@ -33,7 +33,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -53,15 +53,15 @@ "x-ratelimit-reset": "24" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-returns-availability-as-json-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-returns-availability-as-json-with-output-json/interactions.json index df870ec844..4a94a66950 100644 --- a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-returns-availability-as-json-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-returns-availability-as-json-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain/check-availability", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain/check-availability", "query": {}, "headers": { "accept-encoding": "gzip", @@ -33,7 +33,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -53,15 +53,15 @@ "x-ratelimit-reset": "22" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-delete-removes-vanity-subdomain/interactions.json b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-delete-removes-vanity-subdomain/interactions.json index 3ba267e522..7cb14cd3d4 100644 --- a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-delete-removes-vanity-subdomain/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-delete-removes-vanity-subdomain/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain", "query": {}, "headers": { "accept-encoding": "gzip", @@ -26,7 +26,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -46,15 +46,15 @@ "x-ratelimit-reset": "15" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-debug-shows-http-trace/interactions.json b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-debug-shows-http-trace/interactions.json index 9474b4f967..30f10655b6 100644 --- a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-debug-shows-http-trace/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-debug-shows-http-trace/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "25" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-renders-vanity-subdomain-and-status/interactions.json b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-renders-vanity-subdomain-and-status/interactions.json index 653338d012..466fb0aca8 100644 --- a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-renders-vanity-subdomain-and-status/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-renders-vanity-subdomain-and-status/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "27" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-returns-subdomain-config-as-json-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-returns-subdomain-config-as-json-with-output-json/interactions.json index f76d116570..108ab946c1 100644 --- a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-returns-subdomain-config-as-json-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-returns-subdomain-config-as-json-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "26" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/src/server/placeholder.ts b/apps/cli-e2e/src/server/placeholder.ts index 62c832c569..e0f44a0208 100644 --- a/apps/cli-e2e/src/server/placeholder.ts +++ b/apps/cli-e2e/src/server/placeholder.ts @@ -28,6 +28,14 @@ const LEGACY_KEY_PREFIX_PATTERN = /"prefix": "[A-Za-z0-9]{4,10}"/g; // Only targets 4+ digit integers to avoid touching small status codes or counts. const INTEGER_ID_PATTERN = /"id": \d{4,}/g; +// Project refs and org slugs are 20 lowercase letters. Recording rewrites them to one valid +// ref so fixtures decode against the Management API schema without any replay-time rewriting. +const PROJECT_REF_PATTERN = /\b[a-z]{20}\b/g; +const PROJECT_REF_PATH_SEGMENT = /\/[a-z]{20}(\/|$)/g; + +/** The fixed ref that stands in for every project ref and org slug in recorded fixtures. */ +export const REPLAY_PROJECT_REF = "replayfixtureprojref"; + // A fixed valid ISO 8601 timestamp used in place of real timestamps so that // date-parsing code on response fields doesn't fail. const FIXED_TIMESTAMP = "2000-01-01T00:00:00Z"; @@ -45,10 +53,8 @@ export function applyPlaceholders(input: string): { output: string } { // Docker IDs — normalize before the 20-char project-ref pattern fires on sub-matches. output = output.replace(DOCKER_SHA256_PATTERN, "sha256:__IMAGE_ID__"); output = output.replace(DOCKER_FULL_ID_PATTERN, "__CONTAINER_ID__"); - // 20-char lowercase alpha strings — project refs - output = output.replace(/\b[a-z]{20}\b/g, (match) => - match.length !== 20 ? match : "__PROJECT_REF__", - ); + // 20-char lowercase alpha strings — project refs and org slugs + output = output.replace(PROJECT_REF_PATTERN, REPLAY_PROJECT_REF); output = output.replace(ISO_TIMESTAMP_PATTERN, FIXED_TIMESTAMP); // Rate limit headers vary per recording session — normalise to fixed valid integers. output = output.replace(RATE_LIMIT_RESET_PATTERN, '"x-ratelimit-reset": "60"'); @@ -61,29 +67,6 @@ export function applyPlaceholders(input: string): { output: string } { return { output }; } -// `__PROJECT_REF__` is 15 characters, but the Management API schema requires -// `^[a-z]{20}$`, and response decoding rejects a value that doesn't match. When -// serving a recorded response, substitute any field whose value is *exactly* -// the placeholder back to a valid 20-char ref; leave substring occurrences such -// as `db.__PROJECT_REF__.supabase.red` untouched so placeholder-host assertions still match. -const PROJECT_REF_VALUE = /"__PROJECT_REF__"/g; -const PLACEHOLDER_PROJECT_REF = "abcdefghijklmnopqrst"; - -/** Extract the 20-char project ref from a `/v1/projects/` request path, - * falling back to a stable placeholder ref for refless endpoints (e.g. the - * project list). */ -export function projectRefFromPath(urlPath: string): string { - const match = urlPath.match(/\/projects\/([a-z]{20})(?:\/|$)/); - return match?.[1] ?? PLACEHOLDER_PROJECT_REF; -} - -/** Replace exact-match `__PROJECT_REF__` string values in a serialized JSON - * body with a schema-valid 20-char ref. Operates on the JSON string so only - * full quoted values are rewritten, never substrings. */ -export function restoreProjectRef(json: string, ref: string): string { - return json.replace(PROJECT_REF_VALUE, `"${ref}"`); -} - /** Normalize dynamic segments in a URL path to stable unnumbered placeholders. * Apply this to both the stored fixture path and the incoming request path so * both sides of a scenario comparison transform identically. */ @@ -91,7 +74,7 @@ export function normalizeUrlPath(urlPath: string): string { return ( urlPath .replace(UUID_PATTERN, "__UUID__") - .replace(/\/[a-z]{20}(\/|$)/g, "/__PROJECT_REF__$1") + .replace(PROJECT_REF_PATH_SEGMENT, `/${REPLAY_PROJECT_REF}$1`) // Docker API version (/v1.47/ → /__DOCKER_VERSION__/) so fixture keys are // stable across minor Docker Engine upgrades that bump the negotiated API version. .replace(/\/v1\.\d+(\/|$)/g, "/__DOCKER_VERSION__$1") diff --git a/apps/cli-e2e/src/server/replay-server.ts b/apps/cli-e2e/src/server/replay-server.ts index 23b4a869db..8706bc8736 100644 --- a/apps/cli-e2e/src/server/replay-server.ts +++ b/apps/cli-e2e/src/server/replay-server.ts @@ -9,13 +9,7 @@ import type { FixtureStore, } from "./fixture-loader.ts"; import { loadFixtures, loadScenario } from "./fixture-loader.ts"; -import { - applyPlaceholders, - fixtureKey, - normalizeUrlPath, - projectRefFromPath, - restoreProjectRef, -} from "./placeholder.ts"; +import { applyPlaceholders, fixtureKey, normalizeUrlPath } from "./placeholder.ts"; import { matchFixture, resetCounters, sortBody, type SequenceCounters } from "./request-matcher.ts"; import type { PgFixture, PgMockHandle } from "./pg-mock.ts"; @@ -246,14 +240,14 @@ export async function startReplayServer(options: ReplayServerOptions): Promise= 500; + return Response.json(error.body, { + status: error.status, + ...(retryable ? { headers: { "Retry-After": "0" } } : {}), + }); +} + +/** Build an API response, respecting HTTP no-body status codes (204, 304, 205). */ function buildApiResponse( body: unknown, status: number, headers: Record, - projectRef: string, ): Response { if (status === 204 || status === 304 || status === 205) { return new Response(null, { status, headers }); @@ -776,7 +771,7 @@ function buildApiResponse( if (body === null) { return new Response(null, { status, headers }); } - return new Response(restoreProjectRef(JSON.stringify(body), projectRef), { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers }, }); @@ -800,7 +795,6 @@ function serveFromFixtures( result.entry.response.body, result.entry.response.status, result.entry.response.headers, - projectRefFromPath(pathname), ); } @@ -892,7 +886,6 @@ function serveFromScenario( expected.response.body, expected.response.status, expected.response.headers, - projectRefFromPath(pathname), ); } diff --git a/apps/cli-e2e/src/tests/domains.e2e.test.ts b/apps/cli-e2e/src/tests/domains.e2e.test.ts index 2cc7ad8133..72cb5f37bc 100644 --- a/apps/cli-e2e/src/tests/domains.e2e.test.ts +++ b/apps/cli-e2e/src/tests/domains.e2e.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "vitest"; +import { REPLAY_PROJECT_REF } from "../server/placeholder.ts"; import { testBehaviour } from "./test-context"; import { isRecording, PROJECT_REF } from "./env"; @@ -44,7 +45,9 @@ describe("domains", () => { const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("www.urgsimurksi.xyz CNAME -> __PROJECT_REF__.supabase.red"); + expect(result.stderr).toContain( + `www.urgsimurksi.xyz CNAME -> ${REPLAY_PROJECT_REF}.supabase.red`, + ); }); testBehaviour.skipIf(isRecording)("domain activated", async ({ run }) => { @@ -65,7 +68,7 @@ describe("domains", () => { errors: [], messages: [], result: { - custom_origin_server: "__PROJECT_REF__.supabase.red", + custom_origin_server: `${REPLAY_PROJECT_REF}.supabase.red`, hostname: "www.urgsimurksi.xyz", id: "00000000-0000-0000-0000-000000000000", ownership_verification: { diff --git a/apps/cli-e2e/src/tests/env.ts b/apps/cli-e2e/src/tests/env.ts index 086843f3d8..9bd1c8f850 100644 --- a/apps/cli-e2e/src/tests/env.ts +++ b/apps/cli-e2e/src/tests/env.ts @@ -1,3 +1,5 @@ +import { REPLAY_PROJECT_REF } from "../server/placeholder.ts"; + type CliE2eMode = "replay" | "record"; // Runtime mode. `replay` (default) serves recorded fixtures; `record` proxies to @@ -34,13 +36,12 @@ export const ACCESS_TOKEN = // Region for the fresh recording project. export const REGION = process.env["CLI_E2E_REGION"] ?? "us-east-1"; -// In replay mode any 20-char lowercase alpha string normalises to __PROJECT_REF__ -// in the fixture key. In record mode supply a real project ref via env. -export const PROJECT_REF = process.env["SUPABASE_TEST_PROJECT_REF"] ?? "aaaaaaaaaaaaaaaaaaaa"; +// Recorded fixtures store every project ref and org slug as REPLAY_PROJECT_REF, so replay +// requests use it too. In record mode supply a real project ref via env. +export const PROJECT_REF = process.env["SUPABASE_TEST_PROJECT_REF"] ?? REPLAY_PROJECT_REF; -// In replay mode any 20-char lowercase alpha string normalises to __PROJECT_REF__. // In record mode supply a real org slug via env, or let the resolver derive it. -export const ORG_ID = process.env["SUPABASE_TEST_ORG_ID"] ?? "bbbbbbbbbbbbbbbbbbbb"; +export const ORG_ID = process.env["SUPABASE_TEST_ORG_ID"] ?? REPLAY_PROJECT_REF; // UUID of an existing SAML provider on the staging project. // In replay mode any UUID normalises to __UUID__ in fixture paths. @@ -52,7 +53,7 @@ export const PROVIDER_ID = // In replay mode any UUID normalises to __UUID__ in fixture paths. // In record mode supply a real snippet UUID via env. export const SNIPPET_ID = - process.env["SUPABASE_TEST_SNIPPET_ID"] ?? "00000000-0000-0000-0000-000000000001"; + process.env["SUPABASE_TEST_SNIPPET_ID"] ?? "00000000-0000-4000-8000-000000000001"; // Unix epoch seconds for a PITR restore timestamp within the staging project's backup window. // In replay mode the replay server serves responses in order regardless of the request body value. diff --git a/apps/cli-e2e/src/tests/orgs.e2e.test.ts b/apps/cli-e2e/src/tests/orgs.e2e.test.ts index 8335185225..028ab99aed 100644 --- a/apps/cli-e2e/src/tests/orgs.e2e.test.ts +++ b/apps/cli-e2e/src/tests/orgs.e2e.test.ts @@ -10,7 +10,7 @@ describe("orgs", () => { const result = await run(["orgs", "list"]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("ID"); - expect(result.stdout).toMatch(/[a-z]{20}|__PROJECT_REF__/); + expect(result.stdout).toMatch(/[a-z]{20}/); }); testBehaviour("returns json output with --output json", async ({ run }) => { @@ -77,7 +77,7 @@ describe("orgs", () => { const result = await run(["orgs", "create", ORG_NAME]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Created organization:"); - expect(result.stdout).toMatch(/[a-z]{20}|__PROJECT_REF__/); + expect(result.stdout).toMatch(/[a-z]{20}/); }); testBehaviour("exits non-zero without name in non-TTY", async ({ run }) => { diff --git a/apps/cli-e2e/src/tests/projects.e2e.test.ts b/apps/cli-e2e/src/tests/projects.e2e.test.ts index 8efc73aa8e..e34dc96926 100644 --- a/apps/cli-e2e/src/tests/projects.e2e.test.ts +++ b/apps/cli-e2e/src/tests/projects.e2e.test.ts @@ -7,7 +7,7 @@ describe("projects", () => { testBehaviour("renders project list", async ({ run }) => { const result = await run(["projects", "list"]); expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/[a-z]{20}|__PROJECT_REF__/); + expect(result.stdout).toMatch(/[a-z]{20}/); expect(result.stdout).toContain("REFERENCE ID"); }); diff --git a/apps/cli-e2e/tests/setup.ts b/apps/cli-e2e/tests/setup.ts index 76395763ac..ae9ea9a70f 100644 --- a/apps/cli-e2e/tests/setup.ts +++ b/apps/cli-e2e/tests/setup.ts @@ -38,8 +38,8 @@ export async function setup({ provide("dockerHostUrl", dockerHostUrl); if (!isRecording) { - // Replay mode — no real API calls; any valid 20-char string works as the - // project ref because fixture paths normalize it to __PROJECT_REF__. + // Replay mode — no real API calls; PROJECT_REF and ORG_ID default to the ref + // recorded fixtures use. provide("projectRef", PROJECT_REF); provide("orgId", ORG_ID); provide("storageBucket", "cli-e2e-bucket"); diff --git a/apps/cli/src/command-internal/go-output.encoders.ts b/apps/cli/src/command-internal/go-output.encoders.ts index 935e0c48b9..8a026f7816 100644 --- a/apps/cli/src/command-internal/go-output.encoders.ts +++ b/apps/cli/src/command-internal/go-output.encoders.ts @@ -61,9 +61,7 @@ function sortKeysDeep(value: unknown): unknown { * Serializes an outbound API request body with sorted keys, Go string escaping, no indentation, * and no trailing newline, matching `json.Marshal`'s struct output. * - * Used on the raw-HTTP code path (`sso add`/`sso update`) whose request bodies the cli-e2e replay - * server compares by string equality against recorded bodies, so key order and escaping must - * match exactly. {@link encodeGoJson} is the parallel for human-facing `--output json`. + * {@link encodeGoJson} is the parallel for human-facing `--output json`. */ export function encodeGoStructJsonBody(value: unknown): string { return encodeGoJsonCompact(sortKeysDeep(value)); diff --git a/apps/cli/src/command-internal/http-errors.ts b/apps/cli/src/command-internal/http-errors.ts index 0bb77c8c60..eb3f93a9c5 100644 --- a/apps/cli/src/command-internal/http-errors.ts +++ b/apps/cli/src/command-internal/http-errors.ts @@ -19,8 +19,8 @@ const MAX_BODY_LEN = 1024; /** * Truncates and sanitizes a response body for inclusion in an error message. - * Shared by handlers that bypass the typed client (e.g. `sso add`/`sso update` - * raw-HTTP POST/PUT) and `mapHttpError`, so both apply the same defense-in-depth. + * Shared by handlers that read raw response bodies (`executeRaw` callers) and + * `mapHttpError`, so both apply the same defense-in-depth. */ export function sanitizeErrorBody(input: string): string { const capped = input.length > MAX_BODY_LEN ? input.slice(0, MAX_BODY_LEN) : input; diff --git a/apps/cli/src/command-internal/login-api.layer.integration.test.ts b/apps/cli/src/command-internal/login-api.layer.integration.test.ts index c2d27e9cd2..6e7da75165 100644 --- a/apps/cli/src/command-internal/login-api.layer.integration.test.ts +++ b/apps/cli/src/command-internal/login-api.layer.integration.test.ts @@ -11,15 +11,20 @@ import { import { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; import { loginApiLayer, loginProfile } from "./login-api.layer.ts"; +const PROFILE = { gotrue_id: "gotrue-user-123", primary_email: "a@b.co", username: "ab" }; + +function setup(opts: Parameters[0]) { + const api = mockCommandPlatformApi(opts); + const layer = loginApiLayer.pipe( + Layer.provide(api.httpClientLayer), + Layer.provide(mockCommandSettings({ workdir: "/unused-workdir" })), + ); + return { api, layer }; +} + describe("loginApiLayer", () => { it.effect("fetches the account identity from GET /v1/profile after login", () => { - const api = mockCommandPlatformApi({ - response: { status: 200, body: { gotrue_id: "gotrue-user-123" } }, - }); - const layer = loginApiLayer.pipe( - Layer.provide(api.httpClientLayer), - Layer.provide(mockCommandSettings({ workdir: "/unused-workdir" })), - ); + const { api, layer } = setup({ response: { status: 200, body: PROFILE } }); return Effect.gen(function* () { const loginApi = yield* LoginApi; const gotrueId = yield* loginApi.fetchGotrueId(DEFAULT_API_URL, VALID_TOKEN); @@ -34,14 +39,19 @@ describe("loginApiLayer", () => { }).pipe(Effect.provide(layer)); }); + it.effect("sends the profile lookup to the given host with the fresh token", () => { + const { api, layer } = setup({ response: { status: 200, body: PROFILE } }); + return Effect.gen(function* () { + const loginApi = yield* LoginApi; + yield* loginApi.fetchGotrueId("https://api.fresh.example", "sbp_fresh_token"); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.url).toBe("https://api.fresh.example/v1/profile"); + expect(api.requests[0]?.headers["authorization"]).toBe("Bearer sbp_fresh_token"); + }).pipe(Effect.provide(layer)); + }); + it.effect("skips the identity stitch when the profile lookup is forbidden", () => { - const api = mockCommandPlatformApi({ - response: { status: 403, body: { message: "Forbidden" } }, - }); - const layer = loginApiLayer.pipe( - Layer.provide(api.httpClientLayer), - Layer.provide(mockCommandSettings({ workdir: "/unused-workdir" })), - ); + const { api, layer } = setup({ response: { status: 403, body: { message: "Forbidden" } } }); return Effect.gen(function* () { const loginApi = yield* LoginApi; const gotrueId = yield* loginApi.fetchGotrueId(DEFAULT_API_URL, VALID_TOKEN); diff --git a/apps/cli/src/command-internal/login-api.layer.ts b/apps/cli/src/command-internal/login-api.layer.ts index 2eb81c228d..14c78dbe13 100644 --- a/apps/cli/src/command-internal/login-api.layer.ts +++ b/apps/cli/src/command-internal/login-api.layer.ts @@ -1,3 +1,4 @@ +import { makeApiClient } from "@supabase/api/effect"; import { Effect, Layer, Option } from "effect"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; @@ -35,7 +36,8 @@ export const loginApiLayer = Layer.effect( return LoginApi.of({ fetchLoginSession: (apiHost: string, sessionId: string, deviceCode: string) => Effect.gen(function* () { - const url = `${apiHost}/platform/cli/login/${sessionId}?device_code=${deviceCode}`; + // oxlint-disable-next-line supabase/no-raw-management-api-url -- not in the Management API OpenAPI spec, so no typed operation exists + const url = `${apiHost}/platform/cli/login/${encodeURIComponent(sessionId)}?device_code=${encodeURIComponent(deviceCode)}`; const request = HttpClientRequest.get(url).pipe( HttpClientRequest.setHeader("User-Agent", cliSettings.userAgent), ); @@ -82,18 +84,23 @@ export const loginApiLayer = Layer.effect( }), ), + // No client is configured yet at login (the token was just obtained), so build one for this + // host and token. fetchGotrueId: (apiHost: string, token: string) => Effect.gen(function* () { - const request = HttpClientRequest.get(`${apiHost}/v1/profile`).pipe( - HttpClientRequest.setHeader("Authorization", `Bearer ${token}`), - HttpClientRequest.setHeader("User-Agent", cliSettings.userAgent), - ); - const response = yield* httpClient.execute(request); - if (response.status !== 200) return Option.none(); - const body = yield* response.json; - const gotrueId = readString(body, "gotrue_id"); - return gotrueId.length > 0 ? Option.some(gotrueId) : Option.none(); - }).pipe(Effect.orElseSucceed(() => Option.none())), + const api = yield* makeApiClient({ + baseUrl: apiHost, + accessToken: token, + userAgent: cliSettings.userAgent, + }); + const profile = yield* api.v1.getProfile(); + return profile.gotrue_id.length > 0 + ? Option.some(profile.gotrue_id) + : Option.none(); + }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.orElseSucceed(() => Option.none()), + ), }); }), ); diff --git a/apps/cli/src/command-internal/project-create-core.ts b/apps/cli/src/command-internal/project-create-core.ts index 55238d6a59..1fb37eaabc 100644 --- a/apps/cli/src/command-internal/project-create-core.ts +++ b/apps/cli/src/command-internal/project-create-core.ts @@ -122,8 +122,8 @@ export const projectCreateCore = Effect.fn("ProjectCreate.run")(function* ( const creating = output.format === "text" ? yield* output.task("Creating project...") : undefined; - // `executeRaw` skips output decoding: the 201 response's `ref` can be the cli-e2e - // `__PROJECT_REF__` placeholder, which the generated schema would reject. + // `executeRaw` skips output decoding so the created project is emitted as the API returned it, + // including fields the generated schema does not declare. const response = yield* api.executeRaw(operationDefinitions.v1CreateAProject, body).pipe( Effect.tapError(() => creating?.fail() ?? Effect.void), Effect.mapError( diff --git a/apps/cli/src/command-internal/raw-http.ts b/apps/cli/src/command-internal/raw-http.ts deleted file mode 100644 index 7d30d2c998..0000000000 --- a/apps/cli/src/command-internal/raw-http.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Option, type Redacted } from "effect"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; - -/** - * Applies the Management API auth + identification headers to a raw `HttpClientRequest`: - * an `Authorization: Bearer` header when a token is present, and the CLI `User-Agent`. - * - * Shared by commands that bypass the typed Management API client and issue raw HTTP - * (e.g. `postgres-config`, `config push`'s cost-matrix fetch). - */ -export function requestWithAuth( - request: HttpClientRequest.HttpClientRequest, - tokenOpt: Option.Option>, - userAgent: string, -): HttpClientRequest.HttpClientRequest { - return request.pipe( - Option.isSome(tokenOpt) ? HttpClientRequest.bearerToken(tokenOpt.value) : (req) => req, - HttpClientRequest.setHeader("User-Agent", userAgent), - ); -} diff --git a/apps/cli/src/command-internal/upgrade-suggest.ts b/apps/cli/src/command-internal/upgrade-suggest.ts index e927d5dbcc..e45dfec831 100644 --- a/apps/cli/src/command-internal/upgrade-suggest.ts +++ b/apps/cli/src/command-internal/upgrade-suggest.ts @@ -2,13 +2,11 @@ import { styleText } from "node:util"; import type { SupabaseApiError } from "@supabase/api/effect"; import { Effect, Option, type Redacted } from "effect"; -import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientError from "effect/http/HttpClientError"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; import type * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { CommandPlatformApi } from "../auth/command-platform-api.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; -import { resolveAccessToken } from "./resolve-token.ts"; import { Analytics } from "../shared/telemetry/analytics.service.ts"; import { EventUpgradeSuggested, @@ -92,10 +90,7 @@ export const gateMapError = * Suggests an upgrade when a request fails with a plan-gate error. Never * fails the caller. * - * The fallback bypasses the typed API client, whose strict response schemas - * reject the cli-e2e replay fixtures' placeholder refs (same workaround as - * `linked-project-cache.layer.ts`). Returns whether the feature was - * confirmed plan-gated. + * Returns whether the feature was confirmed plan-gated. */ export const suggestUpgrade = Effect.fn("UpgradeSuggest.check")(function* (opts: { readonly projectRef: string; @@ -118,8 +113,8 @@ export const suggestUpgrade = Effect.fn("UpgradeSuggest.check")(function* (opts: * Overrides the bearer token of the fallback GETs, complementing `apiUrl`: * a caller passing a reconciled `apiUrl` must also pass that profile's * token, or the stale profile's token would reach the reconciled host. - * `Some` uses that token, `None` sends unauthenticated, `undefined` - * resolves from the service. + * `Some` uses that token, `None` skips the fallback (the client cannot send + * an unauthenticated request), `undefined` uses the client's own token. */ readonly accessToken?: Option.Option>; /** Set false for call sites that must not fire `EventUpgradeSuggested` (vanity check-availability). */ @@ -132,7 +127,6 @@ export const suggestUpgrade = Effect.fn("UpgradeSuggest.check")(function* (opts: const output = yield* Output; const analytics = yield* Analytics; const cliSettings = yield* CommandSettings; - const httpClient = yield* HttpClient.HttpClient; yield* Effect.annotateCurrentSpan("upgrade.gated", false); let gate: @@ -156,60 +150,36 @@ export const suggestUpgrade = Effect.fn("UpgradeSuggest.check")(function* (opts: return false; } - const tokenOpt = - opts.accessToken ?? - (yield* resolveAccessToken.pipe( - Effect.catchTag("PlatformError", () => Effect.succeed(Option.none())), - )); - const authHeader: ( - req: HttpClientRequest.HttpClientRequest, - ) => HttpClientRequest.HttpClientRequest = Option.isSome(tokenOpt) - ? HttpClientRequest.bearerToken(tokenOpt.value) - : (req) => req; - - const apiUrl = opts.apiUrl ?? cliSettings.apiUrl; - const projectReq = HttpClientRequest.get(`${apiUrl}/v1/projects/${opts.projectRef}`).pipe( - authHeader, - HttpClientRequest.setHeader("User-Agent", cliSettings.userAgent), - ); - const projectResp = yield* httpClient.execute(projectReq).pipe(Effect.option); - if (projectResp._tag === "None" || projectResp.value.status !== 200) { + if (opts.accessToken !== undefined && Option.isNone(opts.accessToken)) { return false; } - const projectBody = yield* projectResp.value.json.pipe(Effect.option); - if (projectBody._tag === "None") { + + const api = yield* CommandPlatformApi; + const requestOptions = { + baseUrl: opts.apiUrl, + accessToken: opts.accessToken === undefined ? undefined : opts.accessToken.value, + }; + const project = yield* api.v1 + .getProject({ ref: opts.projectRef }, requestOptions) + .pipe(Effect.option); + if (Option.isNone(project)) { return false; } - const orgSlug = readString(projectBody.value, "organization_slug"); + const orgSlug = project.value.organization_slug; if (orgSlug.length === 0) { return false; } - const entReq = HttpClientRequest.get(`${apiUrl}/v1/organizations/${orgSlug}/entitlements`).pipe( - authHeader, - HttpClientRequest.setHeader("User-Agent", cliSettings.userAgent), - ); - const entResp = yield* httpClient.execute(entReq).pipe(Effect.option); - if (entResp._tag === "None" || entResp.value.status !== 200) { - return false; - } - const entBody = yield* entResp.value.json.pipe(Effect.option); - if (entBody._tag === "None") { - return false; - } - const entitlements = (entBody.value as { entitlements?: unknown }).entitlements; - if (!Array.isArray(entitlements)) { + const entitlements = yield* api.v1 + .getOrganizationEntitlements({ slug: orgSlug }, requestOptions) + .pipe(Effect.option); + if (Option.isNone(entitlements)) { return false; } - const gated = entitlements.some((entry: unknown) => { - if (typeof entry !== "object" || entry === null) return false; - const feature = (entry as { feature?: unknown }).feature; - if (typeof feature !== "object" || feature === null) return false; - const key = (feature as { key?: unknown }).key; - const hasAccess = (entry as { hasAccess?: unknown }).hasAccess; - return key === opts.featureKey && hasAccess === false; - }); + const gated = entitlements.value.entitlements.some( + (entry) => entry.feature.key === opts.featureKey && entry.hasAccess === false, + ); if (!gated) { return false; } diff --git a/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts b/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts index d15908cd2a..4da09f494e 100644 --- a/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts +++ b/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts @@ -349,19 +349,38 @@ describe("suggestUpgrade", () => { }).pipe(Effect.provide(layer)); }); - it.live("a reconciled profile with no token sends the fallback GETs unauthenticated", () => { + it.live( + "a reconciled profile with no token skips the fallback instead of reusing another token", + () => { + const { layer, api, analytics } = setup(); + return Effect.gen(function* () { + const suggested = yield* suggestUpgrade({ + projectRef: VALID_REF, + featureKey: "branching_limit", + statusCode: 402, + accessToken: Option.none(), + }); + expect(suggested).toBe(false); + expect(api.requests).toHaveLength(0); + expect(analytics.captured).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("a caller-provided apiUrl routes the fallback GETs to that host", () => { const { layer, api } = setup(); return Effect.gen(function* () { yield* suggestUpgrade({ projectRef: VALID_REF, featureKey: "branching_limit", statusCode: 402, - accessToken: Option.none(), + apiUrl: "https://api.reconciled.example", + accessToken: Option.some(Redacted.make("sbp_reconciled_token")), }); - expect(api.requests).toHaveLength(2); - for (const req of api.requests) { - expect(req.headers["authorization"]).toBeUndefined(); - } + expect(api.requests.map((req) => new URL(req.url).origin)).toEqual([ + "https://api.reconciled.example", + "https://api.reconciled.example", + ]); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts index 9840f9ef01..2b937a39df 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts @@ -40,6 +40,7 @@ import { commandSettingsLayer } from "../../config/command-settings.layer.ts"; import { linkedProjectCacheLayer } from "../../telemetry/linked-project-cache.layer.ts"; import { TemplateService } from "./bootstrap.templates.ts"; import { bootstrap } from "./bootstrap.handler.ts"; +import type { V1GetProjectOutput } from "@supabase/api/effect"; import type { BootstrapFlags } from "./bootstrap.command.ts"; const FAST_BACKOFF = Schedule.exponential("1 milli"); @@ -53,6 +54,23 @@ const PROJECT = { region: "us-east-1", status: "COMING_UP", }; +// Schema-valid `V1GetProjectOutput`: the linked-project cache decodes it with the typed client. +const PROJECT_DETAIL: typeof V1GetProjectOutput.Type = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-1", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-01-01T00:00:00Z", + status: "COMING_UP", + database: { + host: "db.example.com", + version: "15", + postgres_engine: "15", + release_channel: "ga", + }, +}; const ORGS = [{ id: "org-1", slug: "acme", name: "Acme Inc" }]; const API_KEYS = [{ name: "anon", api_key: "anon-key" }]; const HEALTHY = [{ name: "db", healthy: true, status: "ACTIVE_HEALTHY" }]; @@ -130,7 +148,7 @@ describe("bootstrap linked-project cache location", () => { } // GET /v1/projects/{ref} — read by the linked-project cache. if (recorded.method === "GET" && url.includes(`/v1/projects/${VALID_REF}`)) { - return Effect.succeed(jsonResponse(request, 200, PROJECT)); + return Effect.succeed(jsonResponse(request, 200, PROJECT_DETAIL)); } return Effect.succeed(jsonResponse(request, 404, {})); }; diff --git a/apps/cli/src/commands/branches/update/update.integration.test.ts b/apps/cli/src/commands/branches/update/update.integration.test.ts index ef0377c633..255b736596 100644 --- a/apps/cli/src/commands/branches/update/update.integration.test.ts +++ b/apps/cli/src/commands/branches/update/update.integration.test.ts @@ -264,9 +264,8 @@ describe("branches update integration", () => { const analytics = mockAnalytics(); const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); - // suggestUpgrade bypasses the typed API client (so cli-e2e replay fixtures with - // `__PROJECT_REF__` placeholders don't trip strict schema decode), so all three URLs - // route through the same mockCommandPlatformApi handler the production HttpClient uses. + // The gate's follow-up project and entitlements GETs share the PATCH's + // mockCommandPlatformApi handler. const apiMock = mockCommandPlatformApi({ handler: (request) => Effect.sync(() => { diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index c84c5d2d82..312dc1bd78 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -45,7 +45,7 @@ declines the confirmation prompt. | -2 | branch resolution (UUID) | GET | `/v1/branches/{id}` | 200 | only when `--project-ref` is a UUID (CLI-2289); needs no linked project | | -1 | target detection | GET | `/v1/projects/{ref}` | 200 or 404 | CLI-2168: 200 = plain project (its `name` is shown); 404 = `ref` is a preview branch. Wrapped in a `"Checking project..."` task and bounded at 5s. Entirely best-effort/diagnostic: a TIMEOUT, a transport failure, or any OTHER status degrades to an "unknown" target (never silently "project" — that would skip the confirmation gate for a real branch — and never a hard failure that aborts an otherwise-successful push, e.g. for a scoped token that can write service config but can't read the project record). Skipped entirely when `--project-ref` already named a branch by name/UUID above. | | -1 | branch name lookup | GET | `/v1/projects/{parent_ref}/branches` | 200 | only on the 404 path above, or a UUID-resolved target (never when the probe degrades to "unknown" — that never attempts recovery) — and only when a candidate parent is known (from `--project-ref` or `.temp/linked-project.json`); best-effort, 5s-bounded — a failure only omits the branch's name/parent from the echoed message, it never fails the command | -| 0 | cost matrix | GET | `/v1/projects/{ref}/billing/addons` | 200 | raw HTTP; cost map for 1-variant addons; unchanged, still fetched first | +| 0 | cost matrix | GET | `/v1/projects/{ref}/billing/addons` | 200 | typed client; cost map for 1-variant addons; unchanged, still fetched first | | 1 | effective project config | GET | `/v2/projects/{ref}/config` | 200 | the ONLY read this command makes — see below | | 2 | api | PATCH | `/v1/projects/{ref}/postgrest` | 200 | only when `api` has a pushable change, its response block was returned, and the change is kept | | 3 | db.settings | PUT | `/v1/projects/{ref}/config/database/postgres` | 200 | same conditions (this resource's own local gate is "always on") | diff --git a/apps/cli/src/commands/config/push/push.cost-matrix.ts b/apps/cli/src/commands/config/push/push.cost-matrix.ts index 97bab8e821..1afacf0b32 100644 --- a/apps/cli/src/commands/config/push/push.cost-matrix.ts +++ b/apps/cli/src/commands/config/push/push.cost-matrix.ts @@ -1,11 +1,7 @@ -import { Effect, Predicate } from "effect"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import { Effect } from "effect"; -import { CommandSettings } from "../../../config/command-settings.service.ts"; -import { sanitizeErrorBody } from "../../../command-internal/http-errors.ts"; -import { requestWithAuth } from "../../../command-internal/raw-http.ts"; -import { resolveAccessToken } from "../../../command-internal/resolve-token.ts"; +import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; +import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { ConfigPushListAddonsNetworkError, ConfigPushListAddonsStatusError, @@ -18,58 +14,23 @@ export interface CostItem { readonly price: string; } +const mapListAddonsError = mapHttpError({ + networkError: ConfigPushListAddonsNetworkError, + statusError: ConfigPushListAddonsStatusError, + networkMessage: (cause) => `failed to list addons: ${cause}`, + statusMessage: (status, body) => `unexpected list addons status ${status}: ${body}`, +}); + /** - * GETs `/v1/projects/{ref}/billing/addons` and builds a map of `addon.type` → - * `{ name, price }` for every addon that has exactly one variant. - * - * Uses raw HTTP rather than the typed client: the generated - * `available_addons[].type` is a closed enum (`custom_domain | - * compute_instance | …`) that rejects values accepted as a plain string - * (e.g. the `"api"` GraphQL addon). Mirrors the `sso add` / - * `postgres-config` raw-HTTP precedent. + * Lists the project's addons and builds a map of `addon.type` → `{ name, price }` for every + * addon that has exactly one variant. */ export const getCostMatrix = Effect.fn("config.push.fetchCostMatrix")(function* (ref: string) { - const httpClient = yield* HttpClient.HttpClient; - const cliSettings = yield* CommandSettings; - const tokenOpt = yield* resolveAccessToken; - - const request = requestWithAuth( - HttpClientRequest.get(`${cliSettings.apiUrl}/v1/projects/${ref}/billing/addons`), - tokenOpt, - cliSettings.userAgent, - ); - - const response = yield* httpClient.execute(request).pipe( - Effect.mapError( - (cause) => - new ConfigPushListAddonsNetworkError({ - message: `failed to list addons: ${cause.reason.description ?? cause.reason._tag}`, - }), - ), - ); - - if (response.status !== 200) { - const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - const body = sanitizeErrorBody(rawBody); - return yield* new ConfigPushListAddonsStatusError({ - status: response.status, - body, - message: `unexpected list addons status ${response.status}: ${body}`, - }); - } - - const rawBody = yield* response.text; - const parsed = yield* Effect.try({ - try: () => parseAddonsBody(rawBody), - catch: (cause) => - new ConfigPushListAddonsNetworkError({ - message: `failed to list addons: ${String(cause)}`, - decode: true, - }), - }); + const api = yield* CommandPlatformApi; + const addons = yield* api.v1.listProjectAddons({ ref }).pipe(Effect.catch(mapListAddonsError)); const costMatrix = new Map(); - for (const addon of readAddons(parsed)) { + for (const addon of addons.available_addons) { const variant = addon.variants.length === 1 ? addon.variants[0] : undefined; if (variant !== undefined) { costMatrix.set(addon.type, { name: variant.name, price: variant.price.description }); @@ -78,43 +39,3 @@ export const getCostMatrix = Effect.fn("config.push.fetchCostMatrix")(function* yield* Effect.annotateCurrentSpan("addon.count", costMatrix.size); return costMatrix; }); - -// Native `JSON.parse` keeps the runtime's syntax-error text in the `failed to list addons` message. -function parseAddonsBody(text: string): unknown { - return JSON.parse(text); -} - -interface ParsedAddon { - readonly type: string; - readonly variants: ReadonlyArray<{ - readonly name: string; - readonly price: { readonly description: string }; - }>; -} - -/** Tolerantly extracts `available_addons` with a string `type`, since the API response itself - * uses a plain string, not the enum the generated client declares. */ -function readAddons(parsed: unknown): ReadonlyArray { - if (typeof parsed !== "object" || parsed === null) return []; - const available = Predicate.hasProperty(parsed, "available_addons") - ? parsed.available_addons - : undefined; - if (!Array.isArray(available)) return []; - const addons: Array = []; - for (const entry of available) { - if (typeof entry !== "object" || entry === null) continue; - const type = Predicate.hasProperty(entry, "type") ? entry.type : undefined; - const variantsRaw = Predicate.hasProperty(entry, "variants") ? entry.variants : undefined; - if (typeof type !== "string" || !Array.isArray(variantsRaw)) continue; - const variants = variantsRaw.map((v) => { - const name: unknown = v.name; - const description: unknown = v.price?.description; - return { - name: typeof name === "string" ? name : "", - price: { description: typeof description === "string" ? description : "" }, - }; - }); - addons.push({ type, variants }); - } - return addons; -} diff --git a/apps/cli/src/commands/config/push/push.e2e.test.ts b/apps/cli/src/commands/config/push/push.e2e.test.ts index c32edccdf8..c6bb580d3b 100644 --- a/apps/cli/src/commands/config/push/push.e2e.test.ts +++ b/apps/cli/src/commands/config/push/push.e2e.test.ts @@ -69,10 +69,13 @@ describe("supabase config push", () => { request.method === "GET" && url.pathname === `/v1/projects/${TEST_PROJECT_REF}` ) { - return new Response("unavailable", { status: 503 }); + return new Response("unavailable", { + status: 503, + headers: { "Retry-After": "0" }, + }); } if (request.method === "GET" && url.pathname.endsWith("/billing/addons")) { - return Response.json({ available_addons: [] }); + return Response.json({ available_addons: [], selected_addons: [] }); } if (request.method === "PATCH" && url.pathname.endsWith("/postgrest")) { return request.text().then((body) => { diff --git a/apps/cli/src/commands/config/push/push.integration.test.ts b/apps/cli/src/commands/config/push/push.integration.test.ts index 25cf9d7040..e9729929c4 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -13,7 +13,6 @@ import { Schema, Stdio, } from "effect"; -import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; @@ -294,7 +293,10 @@ function setup(opts: { handler: (request) => { const url = request.url; if (url.includes("/billing/addons")) { - const a = opts.addons ?? { status: 200, body: { available_addons: [] } }; + const a = opts.addons ?? { + status: 200, + body: { selected_addons: [], available_addons: [] }, + }; return Effect.succeed(jsonResponse(request, a.status, a.body)); } if (url.includes("/v2/projects/")) { @@ -1366,25 +1368,6 @@ otp_expiry = 120 // fields (`V1UpdateAuthServiceConfigOutput`) no test should have to hand-author. Storage's write // goes through the real client instead, since its output schema is `Schema.Void`. -function addonsHttpLayer( - body: unknown = { available_addons: [] }, -): Layer.Layer { - return Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ), - ), - ), - ); -} - const BASE_DISABLED = `project_id = "test"\n`; function setupService(opts: { @@ -1416,6 +1399,11 @@ function setupService(opts: { // gated-service scenario keeps working without opting in; overridable via // `opts.v1.getProject`. getProject: () => Effect.succeed(PUSH_TEST_PROJECT), + listProjectAddons: () => + Effect.succeed({ + selected_addons: [], + available_addons: opts.addons?.available_addons ?? [], + }), ...opts.v1, }, raw: { @@ -1428,7 +1416,7 @@ function setupService(opts: { const layer = Layer.mergeAll( buildTestRuntime({ out, - api: { layer: apiMock.layer, httpClientLayer: addonsHttpLayer(opts.addons) }, + api: { layer: apiMock.layer }, cliSettings: mockCommandSettings({ workdir: opts.workdir ?? tempRoot.current, explicitWorkdir: opts.explicitWorkdir ?? false, diff --git a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md index 847e253abf..2701fc2e69 100644 --- a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md @@ -28,9 +28,9 @@ The local lint query runs inside a transaction that is **always rolled back**. | GET | `/v1/projects/{ref}/advisors/security` | Bearer | — | `{ lints: Lint[] }` | | GET | `/v1/projects/{ref}/advisors/performance` | Bearer | — | `{ lints: Lint[] }` | -Issued via **raw HTTP** (not the typed client) with a tolerant parse, so advisor -`name` / `metadata.type` values the API can add do not fail decoding. `--type` -selects which endpoints run: +Issued through the typed client; the generated schema keeps advisor +`name` / `metadata.type` open, so values the API can add do not fail decoding. +`--type` selects which endpoints run: `security` → security only, `performance` → performance only, `all` → both. ## Database (`--local` / `--db-url`) diff --git a/apps/cli/src/commands/db/advisors/advisors.format.ts b/apps/cli/src/commands/db/advisors/advisors.format.ts index 96c0b3fb58..4fdbf6a37f 100644 --- a/apps/cli/src/commands/db/advisors/advisors.format.ts +++ b/apps/cli/src/commands/db/advisors/advisors.format.ts @@ -6,8 +6,6 @@ * byte-for-byte. The only `omitempty` field is `metadata`. */ -import { Predicate } from "effect"; - import { encodeGoJsonIndented } from "../../../command-internal/go-json.ts"; import { makeLevelEnum } from "../../../command-internal/fail-on.ts"; @@ -44,47 +42,6 @@ const asString = (value: unknown): string => const asStringArray = (value: unknown): ReadonlyArray => Array.isArray(value) ? value.map(asString) : []; -/** - * Decodes a JSON value into a plain string field: an absent or `null` value is the zero value - * `""`; a present non-string (number/bool/object/array) throws. Any string value is accepted - * (an intentional unknown-enum tolerance), used only on the typed-API path, not `rows.Scan`. - */ -function requireApiString(value: unknown, field: string): string { - if (value === undefined || value === null) return ""; - if (typeof value !== "string") { - throw new TypeError(`cannot unmarshal advisor ${field} into string`); - } - return value; -} - -/** - * Decodes a JSON value into the `categories` string array, matching the - * established output contract: an empty result collapses to `null` rather - * than `[]`, and the key is always present (no `omitempty`). - * - * Mapping: - * - absent / `null` → `null` (encodes as `"categories":null`) - * - present `[]` → `null` (same collapse) - * - present `["SECURITY",…]` → the string array - * - present non-array → throw - * - non-string element → throw - */ -function requireApiStringArray(value: unknown): ReadonlyArray | null { - if (value === undefined || value === null) return null; - if (!Array.isArray(value)) { - throw new TypeError("cannot unmarshal advisor categories into []string"); - } - if (value.length === 0) return null; - return value.map((element) => { - // A null array element decodes to the zero string "". - if (element === null || element === undefined) return ""; - if (typeof element !== "string") { - throw new TypeError("cannot unmarshal advisor categories element into string"); - } - return element; - }); -} - /** * Normalises a local-query `metadata` (jsonb) cell: the `@effect/sql-pg` driver * returns jsonb already parsed (object), but tolerate a raw JSON string too. @@ -124,117 +81,58 @@ export function scanAdvisorLintRow(row: Record): AdvisorLint { }; } -/** - * The six metadata fields kept, in the established output-contract order. - * - * A JSON `null`/absent `metadata` value is omitted, an object is decoded - * (unknown fields ignored), and any other JSON type — including a - * `fkey_columns` that isn't an array — throws, so a malformed body fails - * rather than silently dropping the metadata. - */ -function projectApiMetadata(value: unknown): Record | undefined { - if (value === undefined || value === null) return undefined; - if (!Predicate.isObject(value)) { - throw new TypeError("cannot unmarshal advisor metadata"); - } - const out: Record = {}; - // Each string subfield decodes absent/null to omitted and a present - // non-string throws. Add in the established output-contract order: entity, - // fkey_columns, fkey_name, name, schema, type. - const optString = (key: string) => { - const field = value[key]; - if (field === undefined || field === null) return; - if (typeof field !== "string") { - throw new TypeError(`cannot unmarshal advisor metadata.${key} into string`); - } - out[key] = field; +/** The lint shape both advisors endpoints decode to; open enums arrive as plain strings. */ +interface ApiLint { + readonly name: string; + readonly title: string; + readonly level: string; + readonly facing: string; + readonly categories: ReadonlyArray; + readonly description: string; + readonly detail: string; + readonly remediation: string; + readonly metadata?: { + readonly schema?: string; + readonly name?: string; + readonly entity?: string; + readonly type?: string; + readonly fkey_name?: string; + readonly fkey_columns?: ReadonlyArray; }; - - optString("entity"); - const fkeyColumns = value["fkey_columns"]; - if (fkeyColumns !== undefined && fkeyColumns !== null) { - if (!Array.isArray(fkeyColumns)) { - throw new TypeError("cannot unmarshal advisor metadata.fkey_columns into []float32"); - } - const normalized: Array = []; - for (const element of fkeyColumns) { - // A null array element decodes to the zero value (0), not a throw. - if (element === null || element === undefined) { - normalized.push(0); - continue; - } - if (typeof element !== "number") { - throw new TypeError("cannot unmarshal advisor metadata.fkey_columns element into float32"); - } - normalized.push(element); - } - out["fkey_columns"] = normalized; - } - optString("fkey_name"); - optString("name"); - optString("schema"); - optString("type"); - return out; + readonly cache_key: string; } /** - * Reads the advisors API response with plain string narrowing instead of the generated - * closed-enum schema, which would reject advisor names/metadata types the API can add: `name` / - * `level` / `facing` / category values pass through as raw strings. - * - * Structurally strict, though: a top-level non-object, a wrongly-typed `lints`/`categories`/ - * `metadata`/`fkey_columns` container, or a non-object lint entry throws — so a malformed 200 - * body fails instead of being reported as "No issues found". A top-level `null` decodes to the - * zero value (no lints). - * - * @throws On structurally invalid input; the caller maps it to `failed to fetch … advisors`. + * Maps decoded API lints to the output shape: metadata keeps its six fields in the established + * order and an empty `categories` collapses to `null`. */ -export function apiResponseToAdvisorLints(parsed: unknown): ReadonlyArray { - if (parsed === null) return []; - if (!Predicate.isObject(parsed)) { - throw new TypeError("cannot unmarshal advisors response"); - } - const lintsRaw = parsed["lints"]; - if (lintsRaw === undefined || lintsRaw === null) return []; - if (!Array.isArray(lintsRaw)) { - throw new TypeError("cannot unmarshal lints into []Lint"); - } - const lints: Array = []; - for (const entry of lintsRaw) { - // A null slice element decodes to the zero-value struct, not a throw; normalize - // null/undefined to an empty record so the field decoders produce zero values. - if (entry === null || entry === undefined) { - lints.push({ - name: "", - title: "", - level: "", - facing: "", - categories: null, - description: "", - detail: "", - remediation: "", - cacheKey: "", - }); - continue; - } - if (!Predicate.isObject(entry)) { - throw new TypeError("cannot unmarshal lint entry into Lint"); - } - const metadata = projectApiMetadata(entry["metadata"]); - lints.push({ - name: requireApiString(entry["name"], "name"), - title: requireApiString(entry["title"], "title"), - level: requireApiString(entry["level"], "level"), - facing: requireApiString(entry["facing"], "facing"), - categories: requireApiStringArray(entry["categories"]), - description: requireApiString(entry["description"], "description"), - detail: requireApiString(entry["detail"], "detail"), - remediation: requireApiString(entry["remediation"], "remediation"), +export function apiLintsToAdvisorLints(lints: ReadonlyArray): ReadonlyArray { + return lints.map((lint) => { + const m = lint.metadata; + const metadata = + m === undefined + ? undefined + : { + ...(m.entity !== undefined ? { entity: m.entity } : {}), + ...(m.fkey_columns !== undefined ? { fkey_columns: m.fkey_columns } : {}), + ...(m.fkey_name !== undefined ? { fkey_name: m.fkey_name } : {}), + ...(m.name !== undefined ? { name: m.name } : {}), + ...(m.schema !== undefined ? { schema: m.schema } : {}), + ...(m.type !== undefined ? { type: m.type } : {}), + }; + return { + name: lint.name, + title: lint.title, + level: lint.level, + facing: lint.facing, + categories: lint.categories.length === 0 ? null : lint.categories, + description: lint.description, + detail: lint.detail, + remediation: lint.remediation, ...(metadata !== undefined ? { metadata } : {}), - cacheKey: requireApiString(entry["cache_key"], "cache_key"), - }); - } - return lints; + cacheKey: lint.cache_key, + }; + }); } /** Advisor-type match: `all` matches every lint, otherwise checks categories. */ diff --git a/apps/cli/src/commands/db/advisors/advisors.format.unit.test.ts b/apps/cli/src/commands/db/advisors/advisors.format.unit.test.ts index 181bf7619d..f9802c61a8 100644 --- a/apps/cli/src/commands/db/advisors/advisors.format.unit.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.format.unit.test.ts @@ -1,8 +1,7 @@ -import { Predicate } from "effect"; -import { assert, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { - apiResponseToAdvisorLints, + apiLintsToAdvisorLints, encodeAdvisorLints, filterAdvisorLints, ADVISORS_LEVEL_ENUM, @@ -108,172 +107,39 @@ describe("scanAdvisorLintRow", () => { }); }); -describe("apiResponseToAdvisorLints (maps Go TestApiResponseToLints)", () => { - it("coerces API fields to strings and projects metadata to the known fields", () => { - const lints = apiResponseToAdvisorLints({ - lints: [ - { - name: "rls_disabled_in_public", - title: "RLS disabled in public", - level: "ERROR", - facing: "EXTERNAL", - categories: ["SECURITY"], - description: "Tables without RLS", - detail: "Table public.users", - remediation: "https://supabase.com/docs", - cache_key: "test_key", - metadata: { schema: "public", entity: "public.users", type: "table", unknown: "x" }, - }, - ], - }); - expect(lints).toHaveLength(1); - expect(lints[0]?.name).toBe("rls_disabled_in_public"); - expect(lints[0]?.level).toBe("ERROR"); - expect(lints[0]?.categories).toEqual(["SECURITY"]); - const metadata = lints[0]?.metadata; - assert(Predicate.isObject(metadata)); - expect(Object.keys(metadata)).toEqual(["entity", "schema", "type"]); - }); - - it("accepts an unknown advisor name (closed-enum divergence guard)", () => { - const lints = apiResponseToAdvisorLints({ - lints: [{ name: "some_brand_new_advisor", level: "WARN", categories: ["SECURITY"] }], - }); - expect(lints[0]?.name).toBe("some_brand_new_advisor"); - }); - - it("returns an empty array for Go zero-value shapes (null, missing/null lints)", () => { - expect(apiResponseToAdvisorLints(null)).toEqual([]); - expect(apiResponseToAdvisorLints({})).toEqual([]); - expect(apiResponseToAdvisorLints({ lints: null })).toEqual([]); - }); - - it("null lints element becomes zero-value lint (Go encoding/json nil-slice decode parity)", () => { - const result = apiResponseToAdvisorLints({ - lints: [ - null, - { - name: "rls_disabled", - level: "ERROR", - facing: "EXTERNAL", - categories: ["SECURITY"], - cache_key: "ck", - }, - ], - }); - expect(result).toHaveLength(2); - expect(result[0]).toEqual({ - name: "", - title: "", - level: "", - facing: "", - categories: null, - description: "", - detail: "", - remediation: "", - cacheKey: "", - }); - expect(result[1]?.name).toBe("rls_disabled"); - expect(result[1]?.level).toBe("ERROR"); - }); - - it("null categories element becomes empty string (Go encoding/json []string null-element parity)", () => { - const result = apiResponseToAdvisorLints({ - lints: [{ name: "x", categories: [null, "SECURITY"] }], - }); - expect(result[0]?.categories).toEqual(["", "SECURITY"]); - }); - - it("throws on structural shapes Go's typed decode rejects", () => { - expect(() => apiResponseToAdvisorLints("nope")).toThrow(); - expect(() => apiResponseToAdvisorLints([])).toThrow(); - expect(() => apiResponseToAdvisorLints({ lints: "nope" })).toThrow(); - expect(() => apiResponseToAdvisorLints({ lints: ["not-an-object"] })).toThrow(); - expect(() => - apiResponseToAdvisorLints({ lints: [{ name: "x", categories: "SECURITY" }] }), - ).toThrow(); - expect(() => apiResponseToAdvisorLints({ lints: [{ name: "x", metadata: "nope" }] })).toThrow(); - expect(() => - apiResponseToAdvisorLints({ - lints: [{ name: "x", metadata: { fkey_columns: "nope" } }], - }), - ).toThrow(); - }); - - it("throws on scalar fields with the wrong JSON type (Go UnmarshalTypeError)", () => { - expect(() => apiResponseToAdvisorLints({ lints: [{ name: 123 }] })).toThrow(); - expect(() => apiResponseToAdvisorLints({ lints: [{ name: "x", level: true }] })).toThrow(); - expect(() => apiResponseToAdvisorLints({ lints: [{ name: "x", categories: [1] }] })).toThrow(); - expect(() => - apiResponseToAdvisorLints({ lints: [{ name: "x", metadata: { schema: 5 } }] }), - ).toThrow(); - }); - - it("treats absent scalar fields as the empty-string zero value (Go json)", () => { - // `categories` absent encodes as `null` too (no omitempty on the field). - const lints = apiResponseToAdvisorLints({ lints: [{ name: "only_name" }] }); - expect(lints[0]).toEqual({ - name: "only_name", - title: "", - level: "", - facing: "", - categories: null, - description: "", - detail: "", - remediation: "", - cacheKey: "", - }); - }); - - it("collapses null and empty categories to null (Go nil-slice parity)", () => { - const fromNull = apiResponseToAdvisorLints({ - lints: [{ name: "x", categories: null }], - }); - expect(fromNull[0]?.categories).toBeNull(); - const fromEmpty = apiResponseToAdvisorLints({ - lints: [{ name: "x", categories: [] }], - }); - expect(fromEmpty[0]?.categories).toBeNull(); - const fromPopulated = apiResponseToAdvisorLints({ - lints: [{ name: "x", categories: ["SECURITY"] }], - }); - expect(fromPopulated[0]?.categories).toEqual(["SECURITY"]); - }); - - it("normalizes null fkey_columns elements to 0 (Go encoding/json float32 zero value)", () => { - const result = apiResponseToAdvisorLints({ - lints: [{ name: "x", metadata: { fkey_columns: [null, 2] } }], - }); - expect(result[0]?.metadata).toMatchObject({ fkey_columns: [0, 2] }); - }); - - it("normalizes a fully-null fkey_columns array to all zeros", () => { - const result = apiResponseToAdvisorLints({ - lints: [{ name: "x", metadata: { fkey_columns: [null, null] } }], - }); - expect(result[0]?.metadata).toMatchObject({ fkey_columns: [0, 0] }); - }); - - it("still throws on non-number, non-null fkey_columns elements (Go UnmarshalTypeError)", () => { - expect(() => - apiResponseToAdvisorLints({ - lints: [{ name: "x", metadata: { fkey_columns: [1, "x"] } }], - }), - ).toThrow("cannot unmarshal advisor metadata.fkey_columns element into float32"); - - expect(() => - apiResponseToAdvisorLints({ - lints: [{ name: "x", metadata: { fkey_columns: [1, true] } }], - }), - ).toThrow(); +describe("apiLintsToAdvisorLints", () => { + const apiLint = { + name: "rls_disabled_in_public", + title: "RLS disabled in public", + level: "ERROR", + facing: "EXTERNAL", + categories: ["SECURITY"], + description: "Tables without RLS", + detail: "Table public.users", + remediation: "https://supabase.com/docs", + cache_key: "test_key", + }; + + it("orders metadata keys by the output contract and omits absent fields", () => { + const [mapped] = apiLintsToAdvisorLints([ + { + ...apiLint, + metadata: { type: "table", schema: "public", fkey_columns: [1, 2], entity: "public.users" }, + }, + ]); + expect(Object.keys(mapped?.metadata as Record)).toEqual([ + "entity", + "fkey_columns", + "schema", + "type", + ]); + expect(mapped?.cacheKey).toBe("test_key"); }); - it("still throws on a non-array fkey_columns (Go UnmarshalTypeError)", () => { - expect(() => - apiResponseToAdvisorLints({ - lints: [{ name: "x", metadata: { fkey_columns: "nope" } }], - }), - ).toThrow("cannot unmarshal advisor metadata.fkey_columns into []float32"); + it("collapses empty categories to null and omits metadata when absent", () => { + const [mapped] = apiLintsToAdvisorLints([{ ...apiLint, categories: [] }]); + expect(mapped?.categories).toBeNull(); + expect("metadata" in (mapped ?? {})).toBe(false); }); }); diff --git a/apps/cli/src/commands/db/advisors/advisors.handler.ts b/apps/cli/src/commands/db/advisors/advisors.handler.ts index 750938f8d5..fcf226533c 100644 --- a/apps/cli/src/commands/db/advisors/advisors.handler.ts +++ b/apps/cli/src/commands/db/advisors/advisors.handler.ts @@ -9,7 +9,6 @@ import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { aqua } from "../../../command-internal/colors.ts"; import { missingAccessTokenMessage } from "../../../auth/access-token.ts"; import { failsOn } from "../../../command-internal/fail-on.ts"; -import { IdentityStitch } from "../../../command-internal/identity-stitch.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { DbConnection } from "../../../command-internal/db-connection.service.ts"; import type { DbSession } from "../../../command-internal/db-connection.service.ts"; @@ -119,9 +118,6 @@ const runLinked = Effect.fn("db.advisors.runLinked")(function* ( const credentials = yield* CommandCredentials; const projectRefResolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; - // Every Management API response is wrapped in identity stitching, including the raw-HTTP - // advisor GETs; one stitcher is shared across both calls so it fires at most once per session. - const { stitch } = yield* IdentityStitch; // The linked-project cache is written whenever the project ref was resolved, even when the // DB-config resolve below fails. Load the ref first (non-prompting, honoring an explicit @@ -163,10 +159,10 @@ const runLinked = Effect.fn("db.advisors.runLinked")(function* ( const lints: Array = []; if (advisorType === "all" || advisorType === "security") { - lints.push(...(yield* fetchSecurityAdvisors(ref, stitch))); + lints.push(...(yield* fetchSecurityAdvisors(ref))); } if (advisorType === "all" || advisorType === "performance") { - lints.push(...(yield* fetchPerformanceAdvisors(ref, stitch))); + lints.push(...(yield* fetchPerformanceAdvisors(ref))); } // The endpoint selection already applied the type filter, so filter by "all". return filterAdvisorLints(lints, "all", level); diff --git a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts index bf9ea066a0..ca8a0cc973 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -25,6 +25,7 @@ import { import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, + projectResponse, provideEnv, realDbConfigResolver, realLinkedProjectCache, @@ -41,7 +42,6 @@ import { InvalidAccessTokenError } from "../../../auth/errors.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { DbConfigIpv6Error } from "../../../command-internal/db-config.errors.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import { IdentityStitch } from "../../../command-internal/identity-stitch.ts"; import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; import { @@ -69,15 +69,6 @@ const LOCAL_CONN: PgConnInput = { const [SETUP_SQL, QUERY_SQL] = splitLintsSql(); -function jsonParseErrorText(raw: string): string { - try { - JSON.parse(raw); - return ""; - } catch (cause) { - return String(cause); - } -} - /** A local lint row keyed by the column names the `lints.sql` query aliases. */ function lintRow(over: Partial> = {}) { return { @@ -217,24 +208,6 @@ function mockCredentials(opts: { token?: "valid" | "none" | "invalid" } = {}) { return { layer }; } -/** Tracks the raw-HTTP advisor path running the identity stitch. */ -function mockIdentityStitch() { - let calls = 0; - const layer = Layer.succeed(IdentityStitch, { - stitch: () => - Effect.sync(() => { - calls += 1; - }), - stitchedDistinctId: () => undefined, - }); - return { - layer, - get calls() { - return calls; - }, - }; -} - interface SetupOpts { format?: "text" | "json" | "stream-json"; rows?: ReadonlyArray>; @@ -244,8 +217,7 @@ interface SetupOpts { invalidToken?: boolean; ipv6Error?: boolean; securityStatus?: number; - securityNonJson?: boolean; - securityJsonBody?: string; + securityRawBody?: string; securityLints?: ReadonlyArray>; performanceLints?: ReadonlyArray>; /** Raw CLI args for `CliArgs` — drives DB target selection (Changed-based). */ @@ -275,23 +247,11 @@ function setup(opts: SetupOpts = {}) { if (status !== 200) { return Effect.succeed(jsonResponse(request, status, { message: "boom" })); } - if (opts.securityNonJson === true) { - // 200 with a non-JSON content-type (proxy/header regression). + if (opts.securityRawBody !== undefined) { return Effect.succeed( HttpClientResponse.fromWeb( request, - new Response(JSON.stringify({ lints: [] }), { - status: 200, - headers: { "content-type": "text/plain" }, - }), - ), - ); - } - if (opts.securityJsonBody !== undefined) { - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(opts.securityJsonBody, { + new Response(opts.securityRawBody, { status: 200, headers: { "content-type": "application/json" }, }), @@ -304,14 +264,7 @@ function setup(opts: SetupOpts = {}) { return Effect.succeed(jsonResponse(request, 200, { lints: opts.performanceLints ?? [] })); } if (request.method === "GET" && url.endsWith(`/v1/projects/${VALID_REF}`)) { - return Effect.succeed( - jsonResponse(request, 200, { - ref: VALID_REF, - name: "alpha", - organization_id: "org-123", - organization_slug: "acme", - }), - ); + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); } return Effect.succeed(jsonResponse(request, 404, {})); }, @@ -324,7 +277,6 @@ function setup(opts: SetupOpts = {}) { const credentials = mockCredentials({ token: opts.invalidToken === true ? "invalid" : opts.loggedIn === false ? "none" : "valid", }); - const identityStitch = mockIdentityStitch(); const layer = Layer.mergeAll( out.layer, @@ -344,8 +296,7 @@ function setup(opts: SetupOpts = {}) { : realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), cliSettings, credentials.layer, - identityStitch.layer, - api.httpClientLayer, + api.factoryLayer, Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(CliArgs, { args: opts.args ?? [] }), ); @@ -358,7 +309,6 @@ function setup(opts: SetupOpts = {}) { cache, api, projectRef, - identityStitch, resolver, }; } @@ -572,6 +522,9 @@ describe("db advisors — linked", () => { level: "ERROR", facing: "EXTERNAL", categories: ["SECURITY"], + description: "Detects tables without RLS.", + detail: "Table public.users has RLS disabled", + remediation: "https://supabase.com/docs", cache_key: "sec", }; const performanceLint = { @@ -580,6 +533,9 @@ describe("db advisors — linked", () => { level: "INFO", facing: "EXTERNAL", categories: ["PERFORMANCE"], + description: "Detects unindexed foreign keys.", + detail: "Table public.posts has an unindexed foreign key", + remediation: "https://supabase.com/docs", cache_key: "perf", }; @@ -773,20 +729,6 @@ describe("db advisors — linked", () => { }).pipe(Effect.provide(layer)); }); - it.effect("runs the identity stitch on each advisor response (Go identityTransport)", () => { - // Every Management API response is wrapped in identity stitching; the raw-HTTP advisor path - // must run the same stitch, once per response. - const { layer, identityStitch } = setup({ - securityLints: [securityLint], - performanceLints: [performanceLint], - args: ["--linked"], - }); - return Effect.gen(function* () { - yield* dbAdvisors(flags({ level: Option.some("info") })); - expect(identityStitch.calls).toBe(2); - }).pipe(Effect.provide(layer)); - }); - it.effect("resolves the linked ref via the non-prompting load (Go LoadProjectRef)", () => { // `resolve` opens an interactive project picker on a TTY; `--linked` must avoid it. const { layer, projectRef } = setup({ @@ -861,34 +803,66 @@ describe("db advisors — linked", () => { }, ); - it.effect("fails on a 200 with a non-JSON content type (Go requires json header)", () => { - // The body is only decoded when Content-Type contains "json"; otherwise this fails as a - // status-200 error. - const { layer } = setup({ securityNonJson: true, args: ["--linked"] }); + it.effect("fails with a status-200 error when the advisors body is not JSON", () => { + const { layer } = setup({ securityRawBody: "gateway", args: ["--linked"] }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbAdvisors(flags({ type: Option.some("security") }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("unexpected security advisors status 200"); + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "DbAdvisorsSecurityStatusError", + status: 200, + }); } }).pipe(Effect.provide(layer)); }); - it.effect("fails with the JSON parse error when a JSON advisors body is malformed", () => { - const { layer } = setup({ securityJsonBody: "{ not json", args: ["--linked"] }); + it.effect("fails as a decode error when the advisors body has the wrong shape", () => { + const { layer } = setup({ securityRawBody: '{"lints":"nope"}', args: ["--linked"] }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbAdvisors(flags({ type: Option.some("security") }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({ - message: `failed to fetch security advisors: ${jsonParseErrorText("{ not json")}`, + _tag: "DbAdvisorsSecurityNetworkError", decode: true, }); } }).pipe(Effect.provide(layer)); }); + it.effect("renders advisors whose name and metadata type the contract does not list", () => { + const { layer, out } = setup({ + format: "json", + securityLints: [ + { + ...securityLint, + name: "public_bucket_allows_listing", + categories: ["SECURITY", "COMPLIANCE"], + metadata: { name: "avatars", type: "bucket", extra: "dropped" }, + }, + ], + args: ["--linked"], + }); + return Effect.gen(function* () { + yield* dbAdvisors(flags({ type: Option.some("security") })); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: { + results: [ + expect.objectContaining({ + name: "public_bucket_allows_listing", + categories: ["SECURITY", "COMPLIANCE"], + metadata: { name: "avatars", type: "bucket" }, + }), + ], + }, + }), + ); + }).pipe(Effect.provide(layer)); + }); + it.effect("fails when the advisors API returns a non-200 status", () => { const { layer } = setup({ securityStatus: 500, args: ["--linked"] }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/db/advisors/advisors.layers.ts b/apps/cli/src/commands/db/advisors/advisors.layers.ts index 30b0239646..99aae70ac5 100644 --- a/apps/cli/src/commands/db/advisors/advisors.layers.ts +++ b/apps/cli/src/commands/db/advisors/advisors.layers.ts @@ -17,8 +17,8 @@ import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts * Runtime layer for `supabase db advisors`, which spans two backends: * * - **`--local` / `--db-url`** — the Postgres connection + db-config resolver. - * - **`--linked`** — raw-HTTP advisor GETs, project-ref resolution, and the - * linked-project cache. + * - **`--linked`** — typed advisor GETs via the lazy platform-API factory, project-ref + * resolution, and the linked-project cache. * * Does not use `managementApiRuntimeLayer`: that layer eagerly builds `CommandPlatformApi`, * resolving an access token at layer construction, which would make the auth-free `--local` path @@ -74,10 +74,9 @@ export const dbAdvisorsRuntimeLayer = Layer.mergeAll( credentials, projectRef, linkedProjectCache, - // The one per-command identity stitcher, exposed at top level so the raw-HTTP advisor GETs can - // yield it. The same reference is provided to platformApiFactory / linkedProjectCache / - // dbConfig above, so memoisation makes them all share one `stitchAttempted` guard, firing at - // most once. + platformApiFactory, + // Exposed at top level so `withCommandTelemetry` can read `stitchedDistinctId()`. Memoised by + // reference, so it is the same instance the factory's typed client stitches through. identityStitchLayer, telemetryStateLayer, commandRuntimeLayer(["db", "advisors"]), diff --git a/apps/cli/src/commands/db/advisors/advisors.linked.ts b/apps/cli/src/commands/db/advisors/advisors.linked.ts index fdc8b3c438..3d3b8025ff 100644 --- a/apps/cli/src/commands/db/advisors/advisors.linked.ts +++ b/apps/cli/src/commands/db/advisors/advisors.linked.ts @@ -1,146 +1,47 @@ import { Effect } from "effect"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientError from "effect/http/HttpClientError"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; -import type * as HttpClientResponse from "effect/http/HttpClientResponse"; -import { CommandSettings } from "../../../config/command-settings.service.ts"; -import { sanitizeErrorBody } from "../../../command-internal/http-errors.ts"; -import { requestWithAuth } from "../../../command-internal/raw-http.ts"; -import { resolveAccessToken } from "../../../command-internal/resolve-token.ts"; +import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; +import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { DbAdvisorsPerformanceNetworkError, DbAdvisorsPerformanceStatusError, DbAdvisorsSecurityNetworkError, DbAdvisorsSecurityStatusError, } from "./advisors.errors.ts"; -import { apiResponseToAdvisorLints } from "./advisors.format.ts"; +import { apiLintsToAdvisorLints } from "./advisors.format.ts"; -interface AdvisorEndpoint { - readonly path: "security" | "performance"; - /** - * Builds the network/parse failure (`failed to fetch … advisors: %w`). - * `decode: true` marks a 200-response body decode failure rather than a - * transport failure, even though both fold into the same message path. - */ - readonly network: (message: string, opts?: { readonly decode?: boolean }) => AdvisorNetworkError; - /** Builds the non-200 failure (`unexpected … advisors status %d: %s`). */ - readonly status: (status: number, body: string) => AdvisorStatusError; -} - -type AdvisorNetworkError = DbAdvisorsSecurityNetworkError | DbAdvisorsPerformanceNetworkError; -type AdvisorStatusError = DbAdvisorsSecurityStatusError | DbAdvisorsPerformanceStatusError; - -const describeHttpError = (cause: unknown): string => - HttpClientError.isHttpClientError(cause) - ? (cause.reason.description ?? cause.reason._tag) - : String(cause); - -const decodeAdvisorsBody = (rawBody: string) => apiResponseToAdvisorLints(JSON.parse(rawBody)); +const mapSecurityError = mapHttpError({ + networkError: DbAdvisorsSecurityNetworkError, + statusError: DbAdvisorsSecurityStatusError, + networkMessage: (cause) => `failed to fetch security advisors: ${cause}`, + statusMessage: (status, body) => `unexpected security advisors status ${status}: ${body}`, +}); -/** Identity stitcher: every Management API response is wrapped in identity - * stitching; the raw-HTTP advisor path runs it explicitly. */ -type StitchFn = (response: HttpClientResponse.HttpClientResponse) => Effect.Effect; +const mapPerformanceError = mapHttpError({ + networkError: DbAdvisorsPerformanceNetworkError, + statusError: DbAdvisorsPerformanceStatusError, + networkMessage: (cause) => `failed to fetch performance advisors: ${cause}`, + statusMessage: (status, body) => `unexpected performance advisors status ${status}: ${body}`, +}); -/** - * Shared GET for an advisors endpoint. Uses raw HTTP + a tolerant parse rather - * than the typed client, since the generated schema's closed `name` / - * `metadata.type` literals would reject advisor names / metadata types the - * API can add. - */ -const fetchAdvisors = Effect.fnUntraced(function* ( +export const fetchSecurityAdvisors = Effect.fn("db.advisors.fetchSecurity")(function* ( ref: string, - endpoint: AdvisorEndpoint, - stitch: StitchFn, ) { - const httpClient = yield* HttpClient.HttpClient; - const cliSettings = yield* CommandSettings; - const tokenOpt = yield* resolveAccessToken; - - const request = requestWithAuth( - HttpClientRequest.get(`${cliSettings.apiUrl}/v1/projects/${ref}/advisors/${endpoint.path}`), - tokenOpt, - cliSettings.userAgent, - ); - - const response = yield* httpClient - .execute(request) - .pipe(Effect.mapError((cause) => endpoint.network(describeHttpError(cause)))); - - // Stitch the session identity from the X-Gotrue-Id header, run on every - // Management API response. - yield* stitch(response); - - if (response.status !== 200) { - const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - return yield* endpoint.status(response.status, sanitizeErrorBody(rawBody)); - } - - // The 200 body is only decoded when the Content-Type header contains "json"; - // otherwise the fetcher returns the status-200 error. Match that so a header - // regression returning JSON text isn't accepted as a valid advisor result. - const contentType = response.headers["content-type"] ?? ""; - if (!contentType.toLowerCase().includes("json")) { - const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - return yield* endpoint.status(200, sanitizeErrorBody(rawBody)); - } - - const rawBody = yield* response.text; - // A decode error folds into the same `failed to fetch … advisors: %w` path, - // so map both JSON syntax errors and structural-shape rejections (thrown by - // `apiResponseToAdvisorLints`) to the endpoint's network error. - const lints = yield* Effect.try({ - try: () => decodeAdvisorsBody(rawBody), - catch: (cause) => endpoint.network(String(cause), { decode: true }), - }); + const api = yield* (yield* CommandPlatformApiFactory).make; + const output = yield* api.v1.getSecurityAdvisors({ ref }).pipe(Effect.catch(mapSecurityError)); + const lints = apiLintsToAdvisorLints(output.lints); yield* Effect.annotateCurrentSpan("advisor.count", lints.length); return lints; }); -export const fetchSecurityAdvisors = (ref: string, stitch: StitchFn) => - fetchAdvisors( - ref, - { - path: "security", - network: (message, opts) => - new DbAdvisorsSecurityNetworkError({ - message: `failed to fetch security advisors: ${message}`, - decode: opts?.decode, - }), - status: (status, body) => - new DbAdvisorsSecurityStatusError({ - status, - body, - message: `unexpected security advisors status ${status}: ${body}`, - }), - }, - stitch, - ).pipe( - Effect.withSpan("v1GetSecurityAdvisors", { - attributes: { "api.operation": "v1GetSecurityAdvisors" }, - }), - ); - -export const fetchPerformanceAdvisors = (ref: string, stitch: StitchFn) => - fetchAdvisors( - ref, - { - path: "performance", - network: (message, opts) => - new DbAdvisorsPerformanceNetworkError({ - message: `failed to fetch performance advisors: ${message}`, - decode: opts?.decode, - }), - status: (status, body) => - new DbAdvisorsPerformanceStatusError({ - status, - body, - message: `unexpected performance advisors status ${status}: ${body}`, - }), - }, - stitch, - ).pipe( - Effect.withSpan("v1GetPerformanceAdvisors", { - attributes: { "api.operation": "v1GetPerformanceAdvisors" }, - }), - ); +export const fetchPerformanceAdvisors = Effect.fn("db.advisors.fetchPerformance")(function* ( + ref: string, +) { + const api = yield* (yield* CommandPlatformApiFactory).make; + const output = yield* api.v1 + .getPerformanceAdvisors({ ref }) + .pipe(Effect.catch(mapPerformanceError)); + const lints = apiLintsToAdvisorLints(output.lints); + yield* Effect.annotateCurrentSpan("advisor.count", lints.length); + return lints; +}); diff --git a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md index 8462df5162..71bf687c44 100644 --- a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md @@ -25,7 +25,7 @@ the result as a table or JSON. | Method | Path | Auth | Request body | Response | | ------ | ----------------------------------- | ------ | ------------------- | ------------------------------------------------------------------------------------------------------------ | -| POST | `/v1/projects/{ref}/database/query` | Bearer | `{"query":""}` | 201, JSON array of row objects (raw — the typed client voids the body, so the linked path uses raw HTTP). | +| POST | `/v1/projects/{ref}/database/query` | Bearer | `{"query":""}` | 201, JSON array of row objects (read via `executeRaw`, since the typed operation voids the body). | | GET | `/v1/projects/{ref}` | Bearer | — | 200 → linked-project cache write; any other status → no write. Fired after the query on the `--linked` path. | ## Environment Variables diff --git a/apps/cli/src/commands/db/query/query.handler.ts b/apps/cli/src/commands/db/query/query.handler.ts index 03737525f0..b64b2d3276 100644 --- a/apps/cli/src/commands/db/query/query.handler.ts +++ b/apps/cli/src/commands/db/query/query.handler.ts @@ -1,17 +1,8 @@ -import { - DateTime, - Effect, - FileSystem, - Option, - Path, - Predicate, - Redacted, - Ref, - Schema, -} from "effect"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import { operationDefinitions } from "@supabase/api/effect"; +import { DateTime, Effect, FileSystem, Option, Path, Predicate, Ref, Schema } from "effect"; +import * as HttpClientError from "effect/http/HttpClientError"; +import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; import { CommandCredentials } from "../../../auth/command-credentials.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; @@ -19,6 +10,10 @@ import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.serv import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { TelemetryOutputFormat } from "../../../telemetry/telemetry-output-format.service.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; +import { + sanitizeErrorBody, + unexpectedStatusMessage, +} from "../../../command-internal/http-errors.ts"; import { DbConnection, type PgConnInput } from "../../../command-internal/db-connection.service.ts"; import { AgentFlag, DnsResolverFlag, OutputFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; @@ -172,41 +167,38 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { ); }; - const runLinked = ( - sql: string, - format: ResolvedFormat, - agentMode: boolean, - ref: string, - token: Redacted.Redacted, - ) => + const runLinked = (sql: string, format: ResolvedFormat, agentMode: boolean, ref: string) => Effect.gen(function* () { - const cliSettings = yield* CommandSettings; - const httpClient = yield* HttpClient.HttpClient; + const apiFactory = yield* CommandPlatformApiFactory; + const api = yield* apiFactory.make; - const request = HttpClientRequest.post( - `${cliSettings.apiUrl}/v1/projects/${ref}/database/query`, - ).pipe( - HttpClientRequest.setHeader("Authorization", `Bearer ${Redacted.value(token)}`), - HttpClientRequest.setHeader("User-Agent", cliSettings.userAgent), - HttpClientRequest.bodyJsonUnsafe({ query: sql }), - ); - const { status, body } = yield* Effect.gen(function* () { - const response = yield* httpClient.execute(request); - const text = yield* response.text; - return { status: response.status, body: text }; - }).pipe( + // `v1RunAQuery` declares no response body, so `executeRaw` keeps the row JSON text intact + // (column order, number formatting) for rendering. + const response = yield* api + .executeRaw( + operationDefinitions.v1RunAQuery, + { ref, query: sql }, + { retry: { requestTimeoutMs: Number.POSITIVE_INFINITY } }, + ) + .pipe( + Effect.mapError( + (cause) => + new DbQueryExecError({ + message: `failed to execute query: ${cause}`, + transport: HttpClientError.isHttpClientError(cause), + }), + ), + ); + const body = yield* response.text.pipe( Effect.mapError( (cause) => - new DbQueryExecError({ - message: `failed to execute query: ${cause}`, - transport: true, - }), + new DbQueryExecError({ message: `failed to execute query: ${cause}`, transport: true }), ), ); - if (status !== 201) { + if (response.status !== 201) { return yield* new DbQueryUnexpectedStatusError({ - status, - message: `unexpected status ${status}: ${body}`, + status: response.status, + message: unexpectedStatusMessage(response.status, sanitizeErrorBody(body)), }); } @@ -255,7 +247,7 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { // For `--linked`, the access token is checked and the project ref is loaded before SQL is // resolved, so a missing `--file` or a blocking stdin pipe doesn't mask a login/not-linked // error. - let linkedAuth: { readonly token: Redacted.Redacted; readonly ref: string } | undefined; + let linkedRef: string | undefined; if (Option.isSome(flags.linked)) { const credentials = yield* CommandCredentials; const projectRef = yield* ProjectRefResolver; @@ -284,16 +276,16 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { suggestion: "Run supabase login first.", }); } - linkedAuth = { token: tokenOpt.value, ref }; + linkedRef = ref; } // Resolves the `--db-url`/local connection target before reading `--file`/stdin, so a bad // `--db-url` or config error surfaces ahead of a missing-file error or a blocking stdin read. const localTarget = - linkedAuth === undefined + linkedRef === undefined ? yield* resolver.resolve({ dbUrl: flags.dbUrl, - // linkedAuth handles `--linked`, so this branch's target is `--db-url` or local. + // linkedRef handles `--linked`, so this branch's target is `--db-url` or local. connType: Option.isSome(flags.dbUrl) ? "db-url" : "local", dnsResolver, }) @@ -350,14 +342,14 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { yield* telemetryOutputFormat.set(format); yield* Effect.annotateCurrentSpan({ "db.conn_type": - linkedAuth !== undefined ? "linked" : Option.isSome(flags.dbUrl) ? "db-url" : "local", + linkedRef !== undefined ? "linked" : Option.isSome(flags.dbUrl) ? "db-url" : "local", "query.format": format, "query.agent_mode": agentMode, }); // 3. Linked queries go through the Management API; local/--db-url connect directly. - if (linkedAuth !== undefined) { - return yield* runLinked(sql, format, agentMode, linkedAuth.ref, linkedAuth.token).pipe( + if (linkedRef !== undefined) { + return yield* runLinked(sql, format, agentMode, linkedRef).pipe( Effect.withSpan("db.query.runLinked"), ); } diff --git a/apps/cli/src/commands/db/query/query.integration.test.ts b/apps/cli/src/commands/db/query/query.integration.test.ts index 5e6f21f8bf..d368f3e1d9 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -13,8 +13,6 @@ import { Schema, Stream, } from "effect"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { vi } from "vitest"; @@ -27,6 +25,7 @@ vi.mock("node:net", (importOriginal) => import { VALID_TOKEN, + mockCommandPlatformApi, mockCommandSettings, mockLinkedProjectCacheTracked, mockTelemetryStateTracked, @@ -190,62 +189,6 @@ function mockStdin(opts: { isTTY?: boolean; piped?: string }) { }); } -function mockHttpClient(opts: { - status?: number; - body?: string; - networkFail?: boolean; - route?: (request: { - method: string; - url: string; - }) => { status: number; body: string } | undefined; -}) { - const requests: Array = []; - const calls: Array<{ method: string; url: string }> = []; - const layer = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => { - requests.push(request.url); - calls.push({ method: request.method, url: request.url }); - const routed = opts.route?.({ method: request.method, url: request.url }); - if (routed !== undefined) { - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(routed.body, { - status: routed.status, - headers: { "content-type": "application/json" }, - }), - ), - ); - } - return opts.networkFail === true - ? Effect.fail( - new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ request, description: "ECONNREFUSED" }), - }), - ) - : Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(opts.body ?? "[]", { - status: opts.status ?? 201, - headers: { "content-type": "application/json" }, - }), - ), - ); - }), - ); - return { - layer, - get requests() { - return requests; - }, - get calls() { - return calls; - }, - }; -} - interface SetupOpts { format?: "text" | "json" | "stream-json"; isLocal?: boolean; @@ -278,31 +221,44 @@ function setup(opts: SetupOpts = {}) { const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); const telemetryOutputFormat = mockTelemetryOutputFormat(); - const httpClient = mockHttpClient({ - status: opts.linkedStatus, - body: opts.linkedBody, - networkFail: opts.networkFail, - route: (request) => - opts.realCache === true && - request.method === "GET" && - request.url.endsWith(`/v1/projects/${REF}`) - ? { - status: 200, - body: JSON.stringify({ - ref: REF, - name: "alpha", - organization_id: "org-123", - organization_slug: "acme", - }), - } - : undefined, + const platformApi = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + opts.realCache === true && + request.method === "GET" && + request.url.endsWith(`/v1/projects/${REF}`) + ? new Response( + JSON.stringify({ + ref: REF, + name: "alpha", + organization_id: "org-123", + organization_slug: "acme", + id: REF, + region: "us-east-1", + created_at: "2026-01-01T00:00:00Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.example.com", + version: "15", + postgres_engine: "15", + release_channel: "ga", + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + : new Response(opts.linkedBody ?? "[]", { status: opts.linkedStatus ?? 201 }), + ), + ), + network: opts.networkFail === true ? "fail" : undefined, }); const layer = Layer.mergeAll( out.layer, telemetry.layer, opts.realCache === true ? realLinkedProjectCache({ - httpClient: httpClient.layer, + httpClient: platformApi.httpClientLayer, cliSettings: mockCommandSettings({ workdir: opts.workdir ?? "/work/project" }), }) : cache.layer, @@ -342,10 +298,10 @@ function setup(opts: SetupOpts = {}) { deleteAllProjectCredentials: Effect.die("unexpected project-credential sweep in test"), deleteProjectCredential: () => Effect.die("unexpected project-credential delete in test"), }), - httpClient.layer, + platformApi.factoryLayer, BunServices.layer, ); - return { layer, out, telemetry, cache, telemetryOutputFormat, httpClient }; + return { layer, out, telemetry, cache, telemetryOutputFormat, platformApi }; } const flags = (over: Partial = {}): DbQueryFlags => ({ @@ -734,25 +690,31 @@ describe("db query integration", () => { }); it.effect("queries the linked project over HTTP and writes the linked-project cache", () => { - const { layer, out, cache, httpClient } = setup({ + const { layer, out, cache, platformApi } = setup({ linkedStatus: 201, linkedBody: '[{"name":"alice","id":1}]', }); return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("select 1"), linked: Option.some(true) })); expect(out.stdoutText).toContain("│ name │ id │"); + expect(platformApi.requests).toHaveLength(1); + expect(platformApi.requests[0]).toMatchObject({ + method: "POST", + url: `https://api.supabase.com/v1/projects/${REF}/database/query`, + body: { query: "select 1" }, + }); expect(cache.cached).toBe(true); assertPermissionDrift({ command: "db query", activeFlags: { linked: true }, - requests: httpClient.calls, + requests: platformApi.requests, exact: true, }); }).pipe(Effect.provide(layer)); }); - it.live("fills the linked-project cache through the real cache layer", () => { - const { layer, httpClient } = setup({ + it.effect("fills the linked-project cache through the real cache layer", () => { + const { layer, platformApi } = setup({ linkedStatus: 201, linkedBody: '[{"name":"alice","id":1}]', realCache: true, @@ -764,21 +726,21 @@ describe("db query integration", () => { expect(yield* fs.exists(cachePath)).toBe(false); yield* dbQuery(flags({ sql: Option.some("select 1"), linked: Option.some(true) })); expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ ref: REF }); - expect(httpClient.calls.map(({ method, url }) => `${method} ${url}`)).toEqual([ + expect(platformApi.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ `POST https://api.supabase.com/v1/projects/${REF}/database/query`, `GET https://api.supabase.com/v1/projects/${REF}`, ]); assertPermissionDrift({ command: "db query", activeFlags: { linked: true }, - requests: httpClient.calls, + requests: platformApi.requests, exact: true, }); }).pipe(Effect.provide(layer)); }); - it.live("mints a login role and queries through the real DB config resolver and cache", () => { - const { layer, httpClient } = setup({ + it.effect("mints a login role and queries through the real DB config resolver and cache", () => { + const { layer, platformApi } = setup({ linkedStatus: 201, linkedBody: '[{"name":"alice","id":1}]', realCache: true, @@ -818,7 +780,7 @@ describe("db query integration", () => { method, url: `https://api.supabase.com${path}`, })), - ...httpClient.calls, + ...platformApi.requests, ], exact: true, }); @@ -837,7 +799,7 @@ describe("db query integration", () => { it.effect("queries the project given via --project-ref without a linked workdir", () => { const FLAG_REF = "flagflagflagflagflag"; - const { layer, out, cache, httpClient } = setup({ + const { layer, out, cache, platformApi } = setup({ linkedStatus: 201, linkedBody: '[{"name":"alice","id":1}]', unlinked: true, @@ -852,14 +814,16 @@ describe("db query integration", () => { ); expect(out.stdoutText).toContain("│ name │ id │"); expect( - httpClient.requests.some((url) => url.includes(`/v1/projects/${FLAG_REF}/database/query`)), + platformApi.requests.some((req) => + req.url.includes(`/v1/projects/${FLAG_REF}/database/query`), + ), ).toBe(true); expect(cache.cached).toBe(true); expect(cache.cachedRef).toBe(FLAG_REF); assertPermissionDrift({ command: "db query", activeFlags: { linked: true, "project-ref": FLAG_REF }, - requests: httpClient.calls, + requests: platformApi.requests, exact: true, }); }).pipe(Effect.provide(layer)); @@ -867,7 +831,7 @@ describe("db query integration", () => { it.effect("--project-ref overrides an already-linked workdir's project ref", () => { const FLAG_REF = "flagflagflagflagflag"; - const { layer, cache, httpClient } = setup({ + const { layer, cache, platformApi } = setup({ linkedStatus: 201, linkedBody: '[{"name":"alice","id":1}]', }); @@ -880,7 +844,9 @@ describe("db query integration", () => { }), ); expect( - httpClient.requests.some((url) => url.includes(`/v1/projects/${FLAG_REF}/database/query`)), + platformApi.requests.some((req) => + req.url.includes(`/v1/projects/${FLAG_REF}/database/query`), + ), ).toBe(true); expect(cache.cached).toBe(true); expect(cache.cachedRef).toBe(FLAG_REF); @@ -890,7 +856,7 @@ describe("db query integration", () => { it.effect("rejects --project-ref without --linked (query defaults to the local target)", () => { const FLAG_REF = "flagflagflagflagflag"; - const { layer, out, cache, httpClient } = setup({ result: SELECT_RESULT }); + const { layer, out, cache, platformApi } = setup({ result: SELECT_RESULT }); return Effect.gen(function* () { const exit = yield* dbQuery( flags({ sql: Option.some("select 1"), projectRef: Option.some(FLAG_REF) }), @@ -900,7 +866,7 @@ describe("db query integration", () => { "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", ); expect(out.stdoutText).toBe(""); - expect(httpClient.requests).toEqual([]); + expect(platformApi.requests).toEqual([]); expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); @@ -955,12 +921,22 @@ describe("db query integration", () => { const exit = yield* dbQuery( flags({ sql: Option.some("bad"), linked: Option.some(true) }), ).pipe(Effect.exit); - expect(failMessage(exit)).toContain("unexpected status 400"); + expect(failMessage(exit)).toBe('unexpected status 400: {"message":"syntax error"}'); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }, ); + it.effect("strips terminal control characters from a linked error body", () => { + const { layer } = setup({ linkedStatus: 400, linkedBody: "bad\u001b[31m query" }); + return Effect.gen(function* () { + const exit = yield* dbQuery( + flags({ sql: Option.some("bad"), linked: Option.some(true) }), + ).pipe(Effect.exit); + expect(failMessage(exit)).toBe("unexpected status 400: bad[31m query"); + }).pipe(Effect.provide(layer)); + }); + it.effect("handles an empty linked result array", () => { const { layer, out } = setup({ linkedStatus: 201, linkedBody: "[]" }); return Effect.gen(function* () { @@ -1025,7 +1001,7 @@ describe("db query integration", () => { const exit = yield* dbQuery( flags({ sql: Option.some("select 1"), linked: Option.some(true) }), ).pipe(Effect.exit); - expect(failMessage(exit)).toContain("failed to execute query"); + expect(failMessage(exit)).toContain("failed to execute query: "); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/domains/domains.format.ts b/apps/cli/src/commands/domains/domains.format.ts index e94c37c8fd..cf0b6b82c5 100644 --- a/apps/cli/src/commands/domains/domains.format.ts +++ b/apps/cli/src/commands/domains/domains.format.ts @@ -32,7 +32,8 @@ function getHostnameStatus(response: HostnameResponse): HostnameStatus | undefin * Trailing-newline presence differs by branch and is part of the established output. */ export function formatHostnameStatus(response: HostnameResponse): string { - switch (getHostnameStatus(response)) { + const status = getHostnameStatus(response); + switch (status) { case "5_services_reconfigured": return response.custom_hostname ? `Custom hostname setup completed. Project is now accessible at ${response.custom_hostname}.` @@ -81,6 +82,8 @@ ${customHostname} CNAME -> ${customOriginServer}`; return "Custom hostname configuration not started.\n"; case undefined: return ""; + default: + return `${status}\n`; } } diff --git a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts index 74db96f0fb..319a8939f5 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -782,6 +782,41 @@ describe("functions deploy", () => { }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); + it.effect("retries a rate-limited deploy only through the command's own loop", () => { + let deployAttempts = 0; + const { layer, out, api } = apiDeployHarness((request) => { + if (request.method === "GET") return Effect.succeed(jsonResponse(request, 200, [])); + deployAttempts += 1; + if (deployAttempts === 1) { + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("{}", { status: 429, headers: { "retry-after": "0" } }), + ), + ); + } + return Effect.succeed( + jsonResponse(request, 201, { + id: "function-id", + slug: "hello-world", + name: "hello-world", + status: "ACTIVE", + version: 1, + }), + ); + }); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + yield* functionsDeploy(baseFlags); + + expect(api.requests.filter((request) => request.method === "POST")).toHaveLength(2); + expect(out.stderrText).toContain("Rate limit exceeded while deploying function hello-world."); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + it.effect("reports a malformed functions list with the schema decoder's message", () => { const { layer } = apiDeployHarness((request) => Effect.succeed( @@ -1068,6 +1103,7 @@ describe("functions deploy", () => { function setupBulkDeploy(opts: { readonly deployStatuses: ReadonlyArray; readonly bulkStatuses?: ReadonlyArray; + readonly functionStatuses?: Readonly>; }) { const out = mockOutput({ format: "text" }); let deployCalls = 0; @@ -1091,7 +1127,7 @@ describe("functions deploy", () => { id: `${slug}-id`, slug, name: slug, - status: "ACTIVE", + status: opts.functionStatuses?.[slug] ?? "ACTIVE", version: 2, created_at: 1_687_423_025_152, updated_at: 1_687_423_025_152, @@ -1182,6 +1218,30 @@ describe("functions deploy", () => { }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); + it.effect("fails loudly instead of coercing a function status the API newly reports", () => { + const { api, layer } = setupBulkDeploy({ + deployStatuses: [201, 201], + functionStatuses: { "bye-world": "SUSPENDED" }, + }); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + yield* writeLocalFunction(tempRoot.current, "bye-world"); + + const error = yield* functionsDeploy({ + ...baseFlags, + functionNames: ["hello-world", "bye-world"], + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + message: "failed to read function bye-world: unexpected status SUSPENDED", + }); + const bulkUpdate = api.requests.find((request) => request.method === "PUT"); + expect(bulkUpdate?.body).toMatchObject([{ slug: "hello-world", status: "ACTIVE" }]); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + it.effect("reports the upload failure and the bulk update failure together", () => { const { api, layer } = setupBulkDeploy({ deployStatuses: [201, 409], diff --git a/apps/cli/src/commands/functions/list/list.integration.test.ts b/apps/cli/src/commands/functions/list/list.integration.test.ts index e6723ba6af..f128786f23 100644 --- a/apps/cli/src/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/commands/functions/list/list.integration.test.ts @@ -31,7 +31,6 @@ const SAMPLE_FUNCTION: Functions[number] = { verify_jwt: true, import_map: false, entrypoint_path: "functions/hello-world/index.ts", - import_map_path: null, }; const PIPE_FUNCTION: Functions[number] = { diff --git a/apps/cli/src/commands/network-restrictions/network-restrictions.cidr.ts b/apps/cli/src/commands/network-restrictions/network-restrictions.cidr.ts index c17aeb88d3..34dcf3a56e 100644 --- a/apps/cli/src/commands/network-restrictions/network-restrictions.cidr.ts +++ b/apps/cli/src/commands/network-restrictions/network-restrictions.cidr.ts @@ -142,7 +142,7 @@ export function validateAndPartitionCidrs( * the two flat string arrays the output template expects. */ export function partitionPatchedCidrs( - items: ReadonlyArray<{ readonly address: string; readonly type: "v4" | "v6" }> | undefined, + items: ReadonlyArray<{ readonly address: string; readonly type: string }> | undefined, ): { readonly v4: string[]; readonly v6: string[] } { const v4: string[] = []; const v6: string[] = []; diff --git a/apps/cli/src/commands/notebooks/notebooks.shared.ts b/apps/cli/src/commands/notebooks/notebooks.shared.ts index 2f097c33e6..210ff45e71 100644 --- a/apps/cli/src/commands/notebooks/notebooks.shared.ts +++ b/apps/cli/src/commands/notebooks/notebooks.shared.ts @@ -441,13 +441,23 @@ const readRemoteNotebook = Effect.fnUntraced(function* ( ); const { description, favorite, content, name } = response.data.attributes; + // Response enums are open; the file must hold values a push can send back. + const file = yield* Schema.decodeUnknownEffect(NotebookFileSchema)({ + ...(description === null ? {} : { description }), + favorite, + content: { cells: content.cells }, + }).pipe( + Effect.mapError( + (cause) => + new NotebookFileError({ + detail: `Notebook ${subject} has content this CLI cannot write to a file: ${cause.message}`, + suggestion: "Update the CLI, or edit the notebook in the dashboard.", + }), + ), + ); return { notebook: { id: response.data.id, name } satisfies RemoteNotebook, - file: { - ...(description === null ? {} : { description }), - favorite, - content: { cells: content.cells }, - } satisfies NotebookFile, + file, }; }); diff --git a/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md index 5396015339..0108b84844 100644 --- a/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md @@ -23,6 +23,8 @@ This command does not call a delete endpoint: it fetches the current config, removes the specified keys locally, then sends the remaining object back via `PUT`. +The remaining stored overrides are forwarded unvalidated, so a stored key this CLI version does not know is preserved. The `PUT` is sent once and never retried, since a retried `restart_database` write could restart the database twice, and its response is read as a raw JSON object so every returned key is shown. + ## Environment Variables | Variable | Purpose | Required? | @@ -43,9 +45,8 @@ This command does not call a delete endpoint: it fetches the current config, rem | `1` | initial GET non-2xx (`PostgresConfigGetUnexpectedStatusError`) | | `1` | initial GET transport failure (`PostgresConfigGetNetworkError`) | | `1` | PUT non-2xx (`PostgresConfigDeleteUnexpectedStatusError`) | -| `1` | PUT transport failure (`PostgresConfigDeleteNetworkError`) | -| `1` | request serialization failure (`PostgresConfigDeleteSerializeError`) | -| `1` | invalid JSON response (`PostgresConfigGetUnmarshalError` / `PostgresConfigDeleteUnmarshalError`) | +| `1` | PUT transport failure (`PostgresConfigDeleteNetworkError`); the GET retries on 5xx and transport errors first, the PUT does not | +| `1` | GET body is not a JSON object (`PostgresConfigGetUnmarshalError`); PUT 2xx body is not a JSON object (`PostgresConfigDeleteUnmarshalError`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/postgres-config/delete/delete.handler.ts b/apps/cli/src/commands/postgres-config/delete/delete.handler.ts index 800524b161..aa4db6cd79 100644 --- a/apps/cli/src/commands/postgres-config/delete/delete.handler.ts +++ b/apps/cli/src/commands/postgres-config/delete/delete.handler.ts @@ -31,28 +31,30 @@ export const postgresConfigDelete = Effect.fn("postgres-config.delete")(function yield* Effect.gen(function* () { const deleting = output.format === "text" ? yield* output.task("Deleting Postgres config...") : undefined; - const currentConfig = yield* fetchCurrentPostgresConfig(ref).pipe( + const preserved = yield* fetchCurrentPostgresConfig(ref).pipe( Effect.tapError(() => deleting?.fail() ?? Effect.void), ); for (const key of flags.config) { - delete currentConfig[key.trim()]; + delete preserved[key.trim()]; } - if (flags.noRestart) { - currentConfig["restart_database"] = false; - } - - const updated = yield* putPostgresConfig(ref, currentConfig, { - serializeError: (args) => new PostgresConfigDeleteSerializeError(args), - networkError: (args) => new PostgresConfigDeleteNetworkError(args), - statusError: (args) => new PostgresConfigDeleteUnexpectedStatusError(args), - unmarshalError: (args) => new PostgresConfigDeleteUnmarshalError(args), - networkMessage: (description) => `failed to delete config overrides: ${description}`, - statusMessage: (status, body) => - `unexpected delete config overrides status ${status}: ${body}`, - unmarshalMessage: (description) => `failed to unmarshal delete response: ${description}`, - }).pipe(Effect.tapError(() => deleting?.fail() ?? Effect.void)); + const changes = flags.noRestart ? { restart_database: false } : {}; + + const updated = yield* putPostgresConfig( + ref, + { changes, preserved }, + { + serializeError: (args) => new PostgresConfigDeleteSerializeError(args), + networkError: PostgresConfigDeleteNetworkError, + statusError: PostgresConfigDeleteUnexpectedStatusError, + unmarshalError: (args) => new PostgresConfigDeleteUnmarshalError(args), + networkMessage: (description) => `failed to delete config overrides: ${description}`, + statusMessage: (status, body) => + `unexpected delete config overrides status ${status}: ${body}`, + unmarshalMessage: (description) => `failed to unmarshal delete response: ${description}`, + }, + ).pipe(Effect.tapError(() => deleting?.fail() ?? Effect.void)); yield* deleting?.clear ?? Effect.void; yield* writePostgresConfigOutput(updated); diff --git a/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md b/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md index d22041c018..ee9979af4f 100644 --- a/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md @@ -37,8 +37,8 @@ | `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) - checked before ref resolution/API/telemetry | | `1` | project ref unresolved (`ProjectRefNotLinkedError` / `InvalidProjectRefError`) | | `1` | API non-2xx (`PostgresConfigGetUnexpectedStatusError`) | -| `1` | transport failure (`PostgresConfigGetNetworkError`) | -| `1` | invalid JSON response (`PostgresConfigGetUnmarshalError`) | +| `1` | transport failure (`PostgresConfigGetNetworkError`); the GET retries on 5xx and transport errors first | +| `1` | response body is not a JSON object (`PostgresConfigGetUnmarshalError`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/postgres-config/postgres-config.errors.ts b/apps/cli/src/commands/postgres-config/postgres-config.errors.ts index e193a794fa..a60144d880 100644 --- a/apps/cli/src/commands/postgres-config/postgres-config.errors.ts +++ b/apps/cli/src/commands/postgres-config/postgres-config.errors.ts @@ -44,9 +44,12 @@ export class PostgresConfigUpdateNetworkError extends Data.TaggedError( "PostgresConfigUpdateNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -68,7 +71,7 @@ export class PostgresConfigUpdateUnmarshalError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // Constructed only after a 200 status check when `parseJsonObject` fails — + // Constructed only after a 2xx status check when the body is not a JSON object -- // an API response problem, not a raw status failure. return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; } @@ -88,9 +91,12 @@ export class PostgresConfigDeleteNetworkError extends Data.TaggedError( "PostgresConfigDeleteNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -112,7 +118,7 @@ export class PostgresConfigDeleteUnmarshalError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // Constructed only after a 200 status check when `parseJsonObject` fails — + // Constructed only after a 2xx status check when the body is not a JSON object -- // an API response problem, not a raw status failure. return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; } diff --git a/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts b/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts index bef27b93f2..0c8d43567f 100644 --- a/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts +++ b/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts @@ -1,8 +1,13 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { makeApiClient } from "@supabase/api/effect"; +import { Cause, Effect, Exit, Layer, Option } from "effect"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts"; import { + DEFAULT_API_URL, + VALID_TOKEN, VALID_REF, buildTestRuntime, jsonResponse, @@ -19,8 +24,33 @@ import { postgresConfigUpdate } from "./update/update.handler.ts"; type GoOutput = "env" | "pretty" | "json" | "toml" | "yaml"; +const failure = ( + exit: Exit.Exit, +): { readonly _tag: string; readonly message: string } | undefined => { + if (!Exit.isFailure(exit)) return undefined; + const error = exit.cause.reasons.find(Cause.isFailReason)?.error; + return error instanceof Error + ? { _tag: String(Reflect.get(error, "_tag")), message: error.message } + : undefined; +}; + const tempRoot = useTempWorkdir("supabase-postgres-config-int-"); +// The shared mock disables client retries; this restores the production default so a test can +// prove a command opts a request out of retrying. +const withProductionRetries = ( + api: ReturnType, +): ReturnType => ({ + ...api, + layer: Layer.effect( + CommandPlatformApi, + makeApiClient({ baseUrl: DEFAULT_API_URL, accessToken: VALID_TOKEN }), + ).pipe(Layer.provide(api.httpClientLayer)), +}); + +const textResponse = (request: Parameters[0], status: number, body: string) => + HttpClientResponse.fromWeb(request, new Response(body, { status })); + function runtimeWith(opts: { readonly out: ReturnType; readonly api: ReturnType; @@ -206,6 +236,20 @@ describe("postgres-config get", () => { }).pipe(Effect.provide(layer)); }); + it.effect("shows stored overrides the typed update contract does not model", () => { + const out = mockOutput({ format: "json" }); + const api = mockCommandPlatformApi({ + response: { status: 200, body: { max_connections: 100, stored_only_key: "x" } }, + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* postgresConfigGet({ projectRef: Option.none() }); + const success = out.messages.find((message) => message.type === "success"); + expect(success?.data).toEqual({ max_connections: 100, stored_only_key: "x" }); + }).pipe(Effect.provide(layer)); + }); + it.effect("maps HTTP 503 to the get unexpected-status error", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ response: { status: 503, body: {} } }); @@ -240,57 +284,54 @@ describe("postgres-config get", () => { }); describe("postgres-config update", () => { - it.effect( - "merges current overrides, coerces values, and PUTs arbitrary keys through raw HTTP", - () => { - const out = mockOutput({ format: "text" }); - const api = mockCommandPlatformApi({ - byMethod: { - GET: { status: 200, body: { wal_keep_size: "1GB" } }, - PUT: { - status: 200, - body: { - wal_keep_size: "1GB", - max_connections: 100, - track_commit_timestamp: true, - statement_timeout: "600", - custom_key: "alpha", - restart_database: false, - }, + it.effect("merges current overrides and coerces --config values into the PUT body", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + byMethod: { + GET: { status: 200, body: { wal_keep_size: "1GB" } }, + PUT: { + status: 200, + body: { + wal_keep_size: "1GB", + max_connections: 100, + track_commit_timestamp: true, + statement_timeout: "600", + shared_buffers: "128MB", + restart_database: false, }, }, - }); - const layer = runtimeWith({ out, api }); + }, + }); + const layer = runtimeWith({ out, api }); - return Effect.gen(function* () { - yield* postgresConfigUpdate({ - projectRef: Option.none(), - config: [ - "max_connections=100", - "track_commit_timestamp=true", - "statement_timeout=600", - "custom_key=alpha", - ], - replaceExistingOverrides: false, - noRestart: true, - }); - expect(out.progressEvents[0]?.message).toBe("Updating Postgres config..."); - expect(api.requests).toHaveLength(2); - expect(api.requests[1]?.method).toBe("PUT"); - expect(api.requests[1]?.body).toEqual({ - wal_keep_size: "1GB", - max_connections: 100, - track_commit_timestamp: true, - statement_timeout: "600", - custom_key: "alpha", - restart_database: false, - }); - expect(out.stderrText).toBe( - "- Custom Postgres Config -\n- End of Custom Postgres Config -\n", - ); - }).pipe(Effect.provide(layer)); - }, - ); + return Effect.gen(function* () { + yield* postgresConfigUpdate({ + projectRef: Option.none(), + config: [ + "max_connections=100", + "track_commit_timestamp=true", + "statement_timeout=600", + "shared_buffers=128MB", + ], + replaceExistingOverrides: false, + noRestart: true, + }); + expect(out.progressEvents[0]?.message).toBe("Updating Postgres config..."); + expect(api.requests).toHaveLength(2); + expect(api.requests[1]?.method).toBe("PUT"); + expect(api.requests[1]?.body).toEqual({ + wal_keep_size: "1GB", + max_connections: 100, + track_commit_timestamp: true, + statement_timeout: "600", + shared_buffers: "128MB", + restart_database: false, + }); + expect(out.stderrText).toBe( + "- Custom Postgres Config -\n- End of Custom Postgres Config -\n", + ); + }).pipe(Effect.provide(layer)); + }); it.effect("skips the initial GET in replace mode", () => { const out = mockOutput({ format: "json" }); @@ -357,6 +398,192 @@ describe("postgres-config update", () => { }), ); + it.effect("rejects an unknown --config parameter before sending the PUT", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + byMethod: { GET: { status: 200, body: { wal_keep_size: "1GB" } } }, + }); + const cache = mockLinkedProjectCacheTracked(); + const layer = runtimeWith({ out, api, linkedProjectCache: cache.layer }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + postgresConfigUpdate({ + projectRef: Option.none(), + config: ["max_conections=100", "bogus=1", "max_connections=100"], + replaceExistingOverrides: false, + noRestart: false, + }), + ); + const error = failure(exit); + expect(error?._tag).toBe("PostgresConfigUpdateSerializeError"); + expect(error?.message).toBe( + "unknown Postgres config parameters: max_conections, bogus " + + "(if this is a newly supported parameter, upgrade the CLI)", + ); + expect(api.requests.map((request) => request.method)).toEqual(["GET"]); + expect(cache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "forwards a stored override the typed contract does not model when updating another key", + () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + byMethod: { + GET: { status: 200, body: { wal_keep_size: "1GB", stored_only_key: "x" } }, + PUT: { + status: 200, + body: { wal_keep_size: "1GB", stored_only_key: "x", max_connections: 100 }, + }, + }, + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* postgresConfigUpdate({ + projectRef: Option.none(), + config: ["max_connections=100"], + replaceExistingOverrides: false, + noRestart: false, + }); + expect(api.requests.map((request) => request.method)).toEqual(["GET", "PUT"]); + expect(api.requests[1]?.body).toEqual({ + wal_keep_size: "1GB", + stored_only_key: "x", + max_connections: 100, + }); + expect(out.stdoutText).toContain("stored_only_key"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("lets a --config value replace the stored value of the same key", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + byMethod: { + GET: { status: 200, body: { max_connections: 50, stored_only_key: "x" } }, + PUT: { status: 200, body: { max_connections: 100, stored_only_key: "x" } }, + }, + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* postgresConfigUpdate({ + projectRef: Option.none(), + config: ["max_connections=100"], + replaceExistingOverrides: false, + noRestart: false, + }); + expect(api.requests[1]?.body).toEqual({ max_connections: 100, stored_only_key: "x" }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("reports a value the typed contract rejects with its parameter name", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi(); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + postgresConfigUpdate({ + projectRef: Option.none(), + config: ["max_connections=lots"], + replaceExistingOverrides: true, + noRestart: false, + }), + ); + expect(failure(exit)?.message).toMatch( + /^invalid Postgres config value for max_connections: /, + ); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.effect("rejects a numeric value for a string parameter locally with a unit hint", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi(); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + postgresConfigUpdate({ + projectRef: Option.none(), + config: ["work_mem=4096"], + replaceExistingOverrides: true, + noRestart: false, + }), + ); + const error = failure(exit); + expect(error?._tag).toBe("PostgresConfigUpdateSerializeError"); + expect(error?.message).toMatch(/^invalid Postgres config value for work_mem: /); + expect(error?.message).toContain("(pass the value with its unit, such as 4096kB or 4096ms)"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.effect("sends a value with its unit as a string", () => { + const out = mockOutput({ format: "json" }); + const api = mockCommandPlatformApi({ + byMethod: { PUT: { status: 200, body: { work_mem: "4096kB" } } }, + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* postgresConfigUpdate({ + projectRef: Option.none(), + config: ["work_mem=4096kB"], + replaceExistingOverrides: true, + noRestart: false, + }); + expect(api.requests.map((request) => request.body)).toEqual([{ work_mem: "4096kB" }]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("shows response keys the typed contract does not model", () => { + const out = mockOutput({ format: "json" }); + const api = mockCommandPlatformApi({ + byMethod: { + PUT: { status: 200, body: { max_connections: 100, server_only_key: "x" } }, + }, + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* postgresConfigUpdate({ + projectRef: Option.none(), + config: ["max_connections=100"], + replaceExistingOverrides: true, + noRestart: false, + }); + const success = out.messages.find((message) => message.type === "success"); + expect(success?.data).toEqual({ max_connections: 100, server_only_key: "x" }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("maps a non-JSON 2xx PUT body to the update unmarshal error", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => Effect.sync(() => textResponse(request, 200, "")), + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + postgresConfigUpdate({ + projectRef: Option.none(), + config: ["max_connections=100"], + replaceExistingOverrides: true, + noRestart: false, + }), + ); + const error = failure(exit); + expect(error?._tag).toBe("PostgresConfigUpdateUnmarshalError"); + expect(error?.message).toMatch(/^failed to unmarshal update response: /); + }).pipe(Effect.provide(layer)); + }); + it.effect("fails before ref resolution on malformed config input", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); @@ -390,14 +617,16 @@ describe("postgres-config update", () => { }).pipe(Effect.provide(layer)); }); - it.effect("maps PUT failures to the update unexpected-status error", () => { + it.effect("surfaces a PUT 503 as the update unexpected-status error without retrying", () => { const out = mockOutput({ format: "text" }); - const api = mockCommandPlatformApi({ - byMethod: { - GET: { status: 200, body: { wal_keep_size: "1GB" } }, - PUT: { status: 503, body: {} }, - }, - }); + const api = withProductionRetries( + mockCommandPlatformApi({ + byMethod: { + GET: { status: 200, body: { wal_keep_size: "1GB" } }, + PUT: { status: 503, body: {} }, + }, + }), + ); const layer = runtimeWith({ out, api }); return Effect.gen(function* () { @@ -415,6 +644,7 @@ describe("postgres-config update", () => { expect(causeText).toContain("PostgresConfigUpdateUnexpectedStatusError"); expect(causeText).toContain("unexpected update config overrides status 503"); } + expect(api.requests.filter((request) => request.method === "PUT")).toHaveLength(1); }).pipe(Effect.provide(layer)); }); @@ -505,6 +735,47 @@ describe("postgres-config delete", () => { }).pipe(Effect.provide(layer)); }); + it.effect("removes a stored override the update contract does not model", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + byMethod: { + GET: { status: 200, body: { max_connections: 100, stored_only_key: "x" } }, + PUT: { status: 200, body: { max_connections: 100 } }, + }, + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* postgresConfigDelete({ + projectRef: Option.none(), + config: ["stored_only_key"], + noRestart: false, + }); + expect(api.requests[1]?.body).toEqual({ max_connections: 100 }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("forwards a stored override the update contract does not model", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + byMethod: { + GET: { status: 200, body: { max_connections: 100, stored_only_key: "x" } }, + PUT: { status: 200, body: { stored_only_key: "x", restart_database: false } }, + }, + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* postgresConfigDelete({ + projectRef: Option.none(), + config: ["max_connections"], + noRestart: true, + }); + expect(api.requests.map((request) => request.method)).toEqual(["GET", "PUT"]); + expect(api.requests[1]?.body).toEqual({ stored_only_key: "x", restart_database: false }); + }).pipe(Effect.provide(layer)); + }); + it.effect("emits a stream-json success payload", () => { const out = mockOutput({ format: "stream-json" }); const api = mockCommandPlatformApi({ @@ -569,12 +840,14 @@ describe("postgres-config delete", () => { it.effect("flushes telemetry and caches the ref when delete fails after resolution", () => { const out = mockOutput({ format: "text" }); - const api = mockCommandPlatformApi({ - byMethod: { - GET: { status: 200, body: { max_connections: 100 } }, - PUT: { status: 503, body: {} }, - }, - }); + const api = withProductionRetries( + mockCommandPlatformApi({ + byMethod: { + GET: { status: 200, body: { max_connections: 100 } }, + PUT: { status: 503, body: {} }, + }, + }), + ); const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); const layer = runtimeWith({ @@ -597,11 +870,38 @@ describe("postgres-config delete", () => { const causeText = Cause.pretty(exit.cause); expect(causeText).toContain("PostgresConfigDeleteUnexpectedStatusError"); } + expect(api.requests.filter((request) => request.method === "PUT")).toHaveLength(1); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); + it.effect("maps a non-JSON 2xx PUT body to the delete unmarshal error", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => + request.method === "GET" + ? jsonResponse(request, 200, { max_connections: 100 }) + : textResponse(request, 200, ""), + ), + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + postgresConfigDelete({ + projectRef: Option.none(), + config: ["max_connections"], + noRestart: false, + }), + ); + const error = failure(exit); + expect(error?._tag).toBe("PostgresConfigDeleteUnmarshalError"); + expect(error?.message).toMatch(/^failed to unmarshal delete response: /); + }).pipe(Effect.provide(layer)); + }); + it.effect("surfaces the shared get error when the initial fetch fails", () => { const out = mockOutput({ format: "json" }); const api = mockCommandPlatformApi({ @@ -622,8 +922,7 @@ describe("postgres-config delete", () => { const causeText = Cause.pretty(exit.cause); expect(causeText).toContain("PostgresConfigGetUnexpectedStatusError"); } - expect(api.requests).toHaveLength(1); - expect(api.requests[0]?.method).toBe("GET"); + expect(api.requests.map((request) => request.method)).not.toContain("PUT"); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/postgres-config/postgres-config.shared.ts b/apps/cli/src/commands/postgres-config/postgres-config.shared.ts index 0066d10ce2..87f89db445 100644 --- a/apps/cli/src/commands/postgres-config/postgres-config.shared.ts +++ b/apps/cli/src/commands/postgres-config/postgres-config.shared.ts @@ -1,22 +1,18 @@ -import { Effect, Option, Schema } from "effect"; -import * as HttpClient from "effect/http/HttpClient"; -import type * as HttpClientError from "effect/http/HttpClientError"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import { operationDefinitions } from "@supabase/api/effect"; +import { Effect, Option, Schema, SchemaIssue } from "effect"; -import { CommandSettings } from "../../config/command-settings.service.ts"; +import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts"; import { OutputFlag } from "../../command-internal/global-flags.ts"; import { Output } from "../../shared/output/output.service.ts"; import { renderGlamourTable } from "../../output/glamour-table.ts"; -import { - encodeEnv, - encodeGoJson, - encodeGoStructJsonBody, - encodeYaml, -} from "../../command-internal/go-output.encoders.ts"; +import { encodeEnv, encodeGoJson, encodeYaml } from "../../command-internal/go-output.encoders.ts"; import { goFormatFloat } from "../../command-internal/go-float.ts"; -import { sanitizeErrorBody } from "../../command-internal/http-errors.ts"; -import { requestWithAuth } from "../../command-internal/raw-http.ts"; -import { resolveAccessToken } from "../../command-internal/resolve-token.ts"; +import { + mapHttpError, + sanitizeErrorBody, + type NetworkErrorFactory, + type StatusErrorFactory, +} from "../../command-internal/http-errors.ts"; import { PostgresConfigGetNetworkError, PostgresConfigGetUnexpectedStatusError, @@ -105,88 +101,137 @@ export function normalizeTimeoutConfig(config: PostgresConfigMap): void { } } -function mapTransportMessage( - cause: HttpClientError.HttpClientError, - message: (description: string) => string, - wrap: (args: { readonly message: string }) => E, -): E { - return wrap({ message: message(cause.reason.description ?? cause.reason._tag) }); -} - const decodeJsonObject = Schema.decodeEffect( Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), ); -function parseJsonObject( - rawBody: string, - errorMessage: (description: string) => string, - wrap: (args: { readonly message: string }) => E, -): Effect.Effect { - return decodeJsonObject(rawBody).pipe( - Effect.mapError((error) => wrap({ message: errorMessage(error.message) })), - ); -} +const getStatusMessage = (status: number, body: string) => + `unexpected config overrides status ${status}: ${body}`; + +const mapGetError = mapHttpError({ + networkError: PostgresConfigGetNetworkError, + statusError: PostgresConfigGetUnexpectedStatusError, + networkMessage: (description) => `failed to retrieve Postgres config overrides: ${description}`, + statusMessage: getStatusMessage, +}); +/** + * Reads the stored overrides as the platform returned them. Read-modify-write callers must see + * every stored key, so the response is not decoded against the typed output schema. + */ export const fetchCurrentPostgresConfig = Effect.fn("postgres-config.fetch-current")(function* ( ref: string, ) { - const httpClient = yield* HttpClient.HttpClient; - const cliSettings = yield* CommandSettings; - const tokenOpt = yield* resolveAccessToken; - - const request = requestWithAuth( - HttpClientRequest.get(`${cliSettings.apiUrl}/v1/projects/${ref}/config/database/postgres`), - tokenOpt, - cliSettings.userAgent, - ); + const api = yield* CommandPlatformApi; - const response = yield* httpClient.execute(request).pipe( - Effect.mapError((cause) => - mapTransportMessage( - cause, - (description) => `failed to retrieve Postgres config overrides: ${description}`, - (args) => new PostgresConfigGetNetworkError(args), - ), - ), - ); + const response = yield* api + .executeRaw(operationDefinitions.v1GetPostgresConfig, { ref }) + .pipe(Effect.catch(mapGetError)); + const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); if (response.status !== 200) { - const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); const body = sanitizeErrorBody(rawBody); return yield* new PostgresConfigGetUnexpectedStatusError({ status: response.status, body, - message: `unexpected config overrides status ${response.status}: ${body}`, + message: getStatusMessage(response.status, body), }); } - const rawBody = yield* response.text; - return yield* parseJsonObject( - rawBody, - (description) => `failed to unmarshal response body: ${description}`, - (args) => new PostgresConfigGetUnmarshalError(args), + const stored = yield* decodeJsonObject(rawBody).pipe( + Effect.mapError( + (error) => + new PostgresConfigGetUnmarshalError({ + message: `failed to unmarshal response body: ${error.message}`, + }), + ), ); + return { ...stored } satisfies PostgresConfigMap; }); +const updateOperation = operationDefinitions.v1UpdatePostgresConfig; +const decodeUpdateInput = Schema.decodeUnknownEffect(updateOperation.inputSchema); + +interface ConfigProblems { + readonly unknownKeys: Array; + readonly invalidValues: Array; +} + +function collectConfigProblems( + issue: SchemaIssue.Issue, + path: ReadonlyArray, + config: PostgresConfigMap, + problems: ConfigProblems, +): void { + switch (issue._tag) { + case "Composite": + for (const nested of issue.issues) collectConfigProblems(nested, path, config, problems); + return; + case "Pointer": + collectConfigProblems(issue.issue, [...path, ...issue.path], config, problems); + return; + case "UnexpectedKey": + problems.unknownKeys.push(path.map(String).join(".")); + return; + default: { + const key = path.map(String).join("."); + const given = config[key]; + const hint = + issue._tag === "InvalidType" && typeof given === "number" + ? ` (pass the value with its unit, such as ${given}kB or ${given}ms)` + : ""; + problems.invalidValues.push(`${key}: ${SchemaIssue.makeFormatterDefault()(issue)}${hint}`); + } + } +} + +function describeConfigProblems(issue: SchemaIssue.Issue, config: PostgresConfigMap) { + const problems: ConfigProblems = { unknownKeys: [], invalidValues: [] }; + collectConfigProblems(issue, [], config, problems); + const messages: Array = []; + const { unknownKeys } = problems; + if (unknownKeys.length > 0) { + const plural = unknownKeys.length > 1; + messages.push( + `unknown Postgres config ${plural ? "parameters" : "parameter"}: ${unknownKeys.join(", ")} ` + + `(if this is a newly supported parameter, upgrade the CLI)`, + ); + } + for (const invalid of problems.invalidValues) { + messages.push(`invalid Postgres config value for ${invalid}`); + } + return messages.join("; "); +} + /** * Per-operation error wiring for {@link putPostgresConfig}. Both `update` and `delete` issue - * the same PUT but tag failures with their own error types and message verbs (mirroring - * `mapHttpError`), keeping each call site's error channel precise. + * the same PUT but tag failures with their own error types and message verbs. */ export interface PutPostgresConfigErrors { readonly serializeError: (args: { readonly message: string }) => SerErr; - readonly networkError: (args: { readonly message: string }) => NetErr; - readonly statusError: (args: { - readonly status: number; - readonly body: string; - readonly message: string; - }) => StatErr; + readonly networkError: NetworkErrorFactory; + readonly statusError: StatusErrorFactory; readonly unmarshalError: (args: { readonly message: string }) => UnmErr; readonly networkMessage: (description: string) => string; readonly statusMessage: (status: number, body: string) => string; readonly unmarshalMessage: (description: string) => string; } +/** + * The overrides to PUT, split by provenance. `changes` is what the user asked for; + * `preserved` is the stored overrides to resend untouched, since the PUT replaces the full map. + */ +export interface PostgresConfigWrite { + readonly changes: PostgresConfigMap; + readonly preserved: PostgresConfigMap; +} + +/** + * PUTs the full overrides map. `changes` is validated against the platform's strict request + * schema, so a bad key or value fails naming the key instead of as an opaque 400; `preserved` is + * forwarded unvalidated so a stored override this CLI's schema does not know is never dropped. + * The PUT is never retried: with `restart_database` a resent write restarts the database twice. + */ export const putPostgresConfig = Effect.fn("postgres-config.put")(function* < SerErr, NetErr, @@ -194,44 +239,32 @@ export const putPostgresConfig = Effect.fn("postgres-config.put")(function* < UnmErr, >( ref: string, - config: PostgresConfigMap, + { changes, preserved }: PostgresConfigWrite, errors: PutPostgresConfigErrors, ) { - const httpClient = yield* HttpClient.HttpClient; - const cliSettings = yield* CommandSettings; - const tokenOpt = yield* resolveAccessToken; - - // Uses raw HTTP instead of the generated input schema, since --config accepts arbitrary - // keys the typed client's OpenAPI-modeled fields don't cover. - const encodedBody = yield* Effect.try({ - try: () => encodeGoStructJsonBody(config), - catch: (cause) => - errors.serializeError({ - message: `failed to serialize config overrides: ${String(cause)}`, - }), - }); + const api = yield* CommandPlatformApi; - const request = requestWithAuth( - HttpClientRequest.put(`${cliSettings.apiUrl}/v1/projects/${ref}/config/database/postgres`).pipe( - HttpClientRequest.bodyText(encodedBody, "application/json"), + const input = yield* decodeUpdateInput( + { ...changes, ref }, + { onExcessProperty: "error", errors: "all" }, + ).pipe( + Effect.mapError((error) => + errors.serializeError({ message: describeConfigProblems(error.issue, changes) }), ), - tokenOpt, - cliSettings.userAgent, + ); + const extraBody = Object.fromEntries( + Object.entries(preserved).filter(([key]) => !Object.hasOwn(changes, key)), ); - const response = yield* httpClient - .execute(request) - .pipe( - Effect.mapError((cause) => - mapTransportMessage(cause, errors.networkMessage, errors.networkError), - ), - ); + const response = yield* api + .executeRaw(updateOperation, input, { retry: { maxRetries: 0 }, extraBody }) + .pipe(Effect.catch(mapHttpError(errors))); - if (response.status !== 200) { - const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + if (response.status < 200 || response.status >= 300) { const body = sanitizeErrorBody(rawBody); return yield* Effect.fail( - errors.statusError({ + new errors.statusError({ status: response.status, body, message: errors.statusMessage(response.status, body), @@ -239,8 +272,12 @@ export const putPostgresConfig = Effect.fn("postgres-config.put")(function* < ); } - const rawBody = yield* response.text; - return yield* parseJsonObject(rawBody, errors.unmarshalMessage, errors.unmarshalError); + const stored = yield* decodeJsonObject(rawBody).pipe( + Effect.mapError((error) => + errors.unmarshalError({ message: errors.unmarshalMessage(error.message) }), + ), + ); + return { ...stored } satisfies PostgresConfigMap; }); export const writePostgresConfigOutput = Effect.fn("postgres-config.write-output")(function* ( diff --git a/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md b/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md index 8bd0b59a1b..6a648f5ce5 100644 --- a/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md @@ -23,6 +23,8 @@ The initial `GET` is skipped when `--replace-existing-overrides` is set. Otherwise the command fetches current overrides first, merges the new values locally, then sends the final merged object back via `PUT`. +The keys passed with `--config` (and `restart_database`) are validated locally against the API's request schema before the `PUT`, which mirrors the platform's strict validation: unknown keys and wrongly typed values fail locally and no `PUT` is sent. Stored overrides are forwarded unvalidated, so a stored key this CLI version does not know is preserved. An unknown-key failure names the key and suggests upgrading the CLI; a number given for a size or duration parameter (`work_mem=4096`) suggests passing the value with its unit (`work_mem=4096kB`). The `PUT` is sent once and never retried, since a retried `restart_database` write could restart the database twice, and its response is read as a raw JSON object so every returned key is shown. + ## Environment Variables | Variable | Purpose | Required? | @@ -44,9 +46,9 @@ The initial `GET` is skipped when `--replace-existing-overrides` is set. Otherwi | `1` | initial GET non-2xx (`PostgresConfigGetUnexpectedStatusError`) | | `1` | initial GET transport failure (`PostgresConfigGetNetworkError`) | | `1` | PUT non-2xx (`PostgresConfigUpdateUnexpectedStatusError`) | -| `1` | PUT transport failure (`PostgresConfigUpdateNetworkError`) | -| `1` | request serialization failure (`PostgresConfigUpdateSerializeError`) | -| `1` | invalid JSON response (`PostgresConfigGetUnmarshalError` / `PostgresConfigUpdateUnmarshalError`) | +| `1` | PUT transport failure (`PostgresConfigUpdateNetworkError`); the GET retries on 5xx and transport errors first, the PUT does not | +| `1` | rejected before the PUT: unknown or wrongly typed config parameter (`PostgresConfigUpdateSerializeError`, e.g. `unknown Postgres config parameter: foo (...)`, `invalid Postgres config value for work_mem: ...`) | +| `1` | GET body is not a JSON object (`PostgresConfigGetUnmarshalError`); PUT 2xx body is not a JSON object (`PostgresConfigUpdateUnmarshalError`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/postgres-config/update/update.handler.ts b/apps/cli/src/commands/postgres-config/update/update.handler.ts index b9f11be4d9..42b0585b8c 100644 --- a/apps/cli/src/commands/postgres-config/update/update.handler.ts +++ b/apps/cli/src/commands/postgres-config/update/update.handler.ts @@ -44,32 +44,38 @@ export const postgresConfigUpdate = Effect.fn("postgres-config.update")(function const updating = output.format === "text" ? yield* output.task("Updating Postgres config...") : undefined; - const finalOverrides = flags.replaceExistingOverrides + const preserved = flags.replaceExistingOverrides ? {} : yield* fetchCurrentPostgresConfig(ref).pipe( Effect.tapError(() => updating?.fail() ?? Effect.void), ); + const changes: Record = {}; for (const [key, value] of Object.entries(nextOverrides)) { - finalOverrides[key] = parseConfigValue(value); + changes[key] = parseConfigValue(value); } if (flags.noRestart) { - finalOverrides["restart_database"] = false; + changes["restart_database"] = false; } - normalizeTimeoutConfig(finalOverrides); + normalizeTimeoutConfig(changes); + normalizeTimeoutConfig(preserved); - const updated = yield* putPostgresConfig(ref, finalOverrides, { - serializeError: (args) => new PostgresConfigUpdateSerializeError(args), - networkError: (args) => new PostgresConfigUpdateNetworkError(args), - statusError: (args) => new PostgresConfigUpdateUnexpectedStatusError(args), - unmarshalError: (args) => new PostgresConfigUpdateUnmarshalError(args), - networkMessage: (description) => `failed to update config overrides: ${description}`, - statusMessage: (status, body) => - `unexpected update config overrides status ${status}: ${body}`, - unmarshalMessage: (description) => `failed to unmarshal update response: ${description}`, - }).pipe(Effect.tapError(() => updating?.fail() ?? Effect.void)); + const updated = yield* putPostgresConfig( + ref, + { changes, preserved }, + { + serializeError: (args) => new PostgresConfigUpdateSerializeError(args), + networkError: PostgresConfigUpdateNetworkError, + statusError: PostgresConfigUpdateUnexpectedStatusError, + unmarshalError: (args) => new PostgresConfigUpdateUnmarshalError(args), + networkMessage: (description) => `failed to update config overrides: ${description}`, + statusMessage: (status, body) => + `unexpected update config overrides status ${status}: ${body}`, + unmarshalMessage: (description) => `failed to unmarshal update response: ${description}`, + }, + ).pipe(Effect.tapError(() => updating?.fail() ?? Effect.void)); yield* updating?.clear ?? Effect.void; yield* writePostgresConfigOutput(updated); diff --git a/apps/cli/src/commands/projects/create/create.integration.test.ts b/apps/cli/src/commands/projects/create/create.integration.test.ts index 21581a10bb..1d58d2da68 100644 --- a/apps/cli/src/commands/projects/create/create.integration.test.ts +++ b/apps/cli/src/commands/projects/create/create.integration.test.ts @@ -533,13 +533,10 @@ describe("projects create integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("tolerates a 201 response with a placeholder/short ref (lenient parse)", () => { - // The typed client validates refs to be 20+ chars; `executeRaw` bypasses that to render - // placeholders verbatim. + it.effect("passes through created-project fields the schema does not declare", () => { const { layer, out } = setup({ - byMethod: { - POST: { status: 201, body: { ...CREATED, id: "__PROJECT_REF__", ref: "__PROJECT_REF__" } }, - }, + goOutput: "json", + byMethod: { POST: { status: 201, body: { ...CREATED, undeclared_field: "kept" } } }, }); return Effect.gen(function* () { yield* projectsCreate({ @@ -549,9 +546,7 @@ describe("projects create integration", () => { dbPassword: Option.some("s3cret-pass"), region: Option.some("us-east-1"), }); - expect(out.stderrText).toContain( - "Created a new project at https://supabase.com/dashboard/project/__PROJECT_REF__", - ); + expect(out.stdoutText).toContain('"undeclared_field": "kept"'); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/projects/list/list.handler.ts b/apps/cli/src/commands/projects/list/list.handler.ts index 0dd0831a48..b85ff1f614 100644 --- a/apps/cli/src/commands/projects/list/list.handler.ts +++ b/apps/cli/src/commands/projects/list/list.handler.ts @@ -77,9 +77,8 @@ export const projectsList = Effect.fn("projects.list")(function* (_flags: Projec const fetching = output.format === "text" ? yield* output.task("Fetching projects...") : undefined; - // `executeRaw` skips response decoding: the generated `ref` schema requires 20+ lowercase - // letters, which placeholder refs in test fixtures don't satisfy. Auth, URL, and headers - // still go through the API client. + // `executeRaw` skips response decoding so projects are emitted as the API returned them, + // including fields the generated schema does not declare. const response = yield* api.executeRaw(operationDefinitions.v1ListAllProjects, {}).pipe( Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.mapError( diff --git a/apps/cli/src/commands/projects/list/list.integration.test.ts b/apps/cli/src/commands/projects/list/list.integration.test.ts index 4b36d39c01..d76f2f94ff 100644 --- a/apps/cli/src/commands/projects/list/list.integration.test.ts +++ b/apps/cli/src/commands/projects/list/list.integration.test.ts @@ -358,14 +358,14 @@ describe("projects list integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("tolerates placeholder/short refs in the response (lenient parse)", () => { - // The typed client rejects refs under 20 chars; the raw-HTTP path renders them verbatim - // so placeholder fixtures still work. - const placeholder = { ...SAMPLE_PROJECT, id: "__PROJECT_REF__", ref: "__PROJECT_REF__" }; - const { layer, out } = setup({ response: [placeholder] }); + it.effect("passes through project fields the schema does not declare", () => { + const { layer, out } = setup({ + goOutput: "json", + response: [{ ...SAMPLE_PROJECT, undeclared_field: "kept" }], + }); return Effect.gen(function* () { yield* projectsList({}); - expect(out.stdoutText).toContain("__PROJECT_REF__"); + expect(out.stdoutText).toContain('"undeclared_field": "kept"'); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/projects/projects.format.ts b/apps/cli/src/commands/projects/projects.format.ts index 3dc996cd3b..e5347f9a5d 100644 --- a/apps/cli/src/commands/projects/projects.format.ts +++ b/apps/cli/src/commands/projects/projects.format.ts @@ -8,9 +8,8 @@ import { formatTimestamp } from "../../command-internal/timestamp.format.ts"; type ApiKey = typeof ApiKeyResponse_Output.Type; /** - * Lenient project record. `projects list`/`create` parse the `/v1/projects` response via the raw - * HTTP client because the typed schema's `ref` pattern (20+ lowercase letters) rejects placeholder - * refs in test fixtures, so projects flow through as plain JSON objects. + * Lenient project record. `projects list`/`create` read the `/v1/projects` response without + * schema decoding, so projects flow through as plain JSON objects exactly as the API returned them. */ export type LinkedProject = Readonly> & { readonly linked: boolean }; diff --git a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts index 2fce5f2bb1..c11e2ad493 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -54,7 +54,10 @@ import { } from "../../../../tests/helpers/storage.ts"; import { unusedStackServices } from "../../../../tests/helpers/unused-stack.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; -import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; interface MockRoute { readonly method: string; @@ -230,12 +233,7 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( handler: (request) => Effect.succeed( request.method === "GET" && request.url.endsWith(`/v1/projects/${projectRefRef}`) - ? jsonResponse(request, 200, { - ref: projectRefRef, - name: "alpha", - organization_id: "org-123", - organization_slug: "acme", - }) + ? jsonResponse(request, 200, projectResponse(projectRefRef)) : request.method === "GET" && request.url.endsWith(`/v1/projects/${projectRefRef}/api-keys`) ? jsonResponse(request, 200, opts.apiKeys ?? defaultApiKeys) diff --git a/apps/cli/src/commands/snippets/download/download.handler.ts b/apps/cli/src/commands/snippets/download/download.handler.ts index b2c5521c17..2b5e2609ad 100644 --- a/apps/cli/src/commands/snippets/download/download.handler.ts +++ b/apps/cli/src/commands/snippets/download/download.handler.ts @@ -1,13 +1,11 @@ -import { Effect, Option, Predicate, Result, Schema } from "effect"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import { operationDefinitions } from "@supabase/api/effect"; +import { Effect, Predicate, Result, Schema } from "effect"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import { CommandSettings } from "../../../config/command-settings.service.ts"; +import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; -import { resolveAccessToken } from "../../../command-internal/resolve-token.ts"; -import { requestWithAuth } from "../../../command-internal/raw-http.ts"; -import { sanitizeErrorBody } from "../../../command-internal/http-errors.ts"; +import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { goQuote } from "../../../command-internal/go-quote.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; @@ -106,23 +104,23 @@ export function parseSnippetUuid(input: string): Result.Result { return Result.succeed(canonicalFromHex(segments.join(""))); } -// Tolerant body parse — see `list.handler.ts` for the rationale. The real -// `/v1/snippets/{id}` payload omits `description`, which the generated schema -// requires, so routing through the typed client fails on real responses. -function asRecord(obj: unknown): Record { - return Predicate.isObjectOrArray(obj) ? { ...obj } : {}; -} +const asRecord = (value: unknown): Record => + Predicate.isObjectOrArray(value) ? { ...value } : {}; + +const decodeSnippet = Schema.decodeUnknownEffect(operationDefinitions.v1GetASnippet.outputSchema); -const decodeSnippetSql = Schema.decodeUnknownOption( - Schema.Struct({ content: Schema.Struct({ sql: Schema.String }) }), -); +const mapDownloadError = mapHttpError({ + networkError: SnippetsDownloadNetworkError, + statusError: SnippetsDownloadUnexpectedStatusError, + networkMessage: (cause) => `failed to download snippet: ${cause}`, + statusMessage: (status, body) => `unexpected download snippet status ${status}: ${body}`, +}); export const snippetsDownload = Effect.fn("snippets.download")(function* ( flags: SnippetsDownloadFlags, ) { const output = yield* Output; - const httpClient = yield* HttpClient.HttpClient; - const cliSettings = yield* CommandSettings; + const api = yield* CommandPlatformApi; const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; @@ -137,46 +135,30 @@ export const snippetsDownload = Effect.fn("snippets.download")(function* ( ), ); - const tokenOpt = yield* resolveAccessToken; - const request = requestWithAuth( - HttpClientRequest.get(`${cliSettings.apiUrl}/v1/snippets/${snippetId}`), - tokenOpt, - cliSettings.userAgent, - ); - const fetching = output.format === "text" ? yield* output.task("Downloading snippet...") : undefined; - const response = yield* httpClient.execute(request).pipe( - Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.mapError( - (cause) => - new SnippetsDownloadNetworkError({ - message: `failed to download snippet: ${cause.reason.description ?? cause.reason._tag}`, - }), - ), - ); - - if (response.status !== 200) { - yield* fetching?.fail() ?? Effect.void; - const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - const body = sanitizeErrorBody(rawBody); - return yield* new SnippetsDownloadUnexpectedStatusError({ - status: response.status, - body, - message: `unexpected download snippet status ${response.status}: ${body}`, - }); - } - + // `executeRaw` keeps the response body intact so JSON output emits it as the API returned + // it; the typed schema decodes a copy for the SQL. + const response = yield* api + .executeRaw(operationDefinitions.v1GetASnippet, { id: snippetId }) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.catch(mapDownloadError), + Effect.tapError(() => fetching?.fail() ?? Effect.void), + ); const rawBody = yield* response.json.pipe( Effect.mapError( (cause) => new SnippetsDownloadNetworkError({ message: `failed to download snippet: ${String(cause)}`, - // 200-response body decode failure — an API-response problem, not - // a transport/network failure. decode: true, }), ), + Effect.tapError(() => fetching?.fail() ?? Effect.void), + ); + const snippet = yield* decodeSnippet(rawBody).pipe( + Effect.catch(mapDownloadError), + Effect.tapError(() => fetching?.fail() ?? Effect.void), ); yield* fetching?.clear ?? Effect.void; @@ -190,10 +172,7 @@ export const snippetsDownload = Effect.fn("snippets.download")(function* ( // `-o`/`--output` is ignored entirely; this always prints the raw SQL — // no branching on `OutputFlag`. - const sql = Option.match(decodeSnippetSql(rawBody), { - onNone: () => "", - onSome: ({ content }) => content.sql, - }); + const sql = snippet.content?.sql ?? ""; yield* output.raw(sql + "\n"); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); }).pipe(Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/snippets/download/download.integration.test.ts b/apps/cli/src/commands/snippets/download/download.integration.test.ts index 4cb790028c..158f149116 100644 --- a/apps/cli/src/commands/snippets/download/download.integration.test.ts +++ b/apps/cli/src/commands/snippets/download/download.integration.test.ts @@ -1,6 +1,6 @@ import { type V1GetASnippetOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, Option, Schema } from "effect"; import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { @@ -49,6 +49,8 @@ interface SetupOpts { response?: unknown; } +const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-snippets-download-int-"); function setup(opts: SetupOpts = {}) { @@ -87,16 +89,40 @@ describe("snippets download integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("prints only a newline in text mode when the snippet carries no SQL string", () => { - const { layer, out } = setup({ - response: { ...SNIPPET_RESPONSE, content: { schema_version: "1.0.0" } }, - }); + it.effect("prints only a newline in text mode when the snippet has no content", () => { + const { layer, out } = setup({ response: { ...SNIPPET_RESPONSE, content: null } }); return Effect.gen(function* () { yield* snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); expect(out.stdoutText).toBe("\n"); }).pipe(Effect.provide(layer)); }); + it.effect("emits null owner, updated_by and content as the API returned them under json", () => { + const body = { ...SNIPPET_RESPONSE, owner: null, updated_by: null, content: null }; + const { layer, out } = setup({ format: "json", response: body }); + return Effect.gen(function* () { + yield* snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(toJson(success?.data)).toBe(toJson(body)); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails with a decode network error when the body does not match the contract", () => { + const { layer } = setup({ response: { id: VALID_ID } }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "SnippetsDownloadNetworkError", + decode: true, + }); + } + }).pipe(Effect.provide(layer)); + }); + // `--output` is ignored entirely: no read of `OutputFlag`, no branching. // Guards against a future refactor adding branch-on-goOutput logic by // mistake — if the flag is consumed, this assertion diverges. diff --git a/apps/cli/src/commands/snippets/list/list.handler.ts b/apps/cli/src/commands/snippets/list/list.handler.ts index 4d7410e109..3207bfcd2b 100644 --- a/apps/cli/src/commands/snippets/list/list.handler.ts +++ b/apps/cli/src/commands/snippets/list/list.handler.ts @@ -1,8 +1,8 @@ -import { Effect, Option, Predicate } from "effect"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import { operationDefinitions } from "@supabase/api/effect"; +import { Effect, Option, Predicate, Schema } from "effect"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import { CommandSettings } from "../../../config/command-settings.service.ts"; +import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; @@ -18,9 +18,7 @@ import { goString, goStruct, } from "../../../command-internal/go-struct-output.encoders.ts"; -import { resolveAccessToken } from "../../../command-internal/resolve-token.ts"; -import { requestWithAuth } from "../../../command-internal/raw-http.ts"; -import { sanitizeErrorBody } from "../../../command-internal/http-errors.ts"; +import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { @@ -32,21 +30,6 @@ import { import { renderSnippetsTable, type SnippetRow } from "../snippets.format.ts"; import type { SnippetsListFlags } from "./list.command.ts"; -// Tolerant accessors for the API response body. The real `/v1/snippets` -// payload omits optional fields the generated schema declares required, so -// routing through the typed client fails with `SchemaError: Missing key …`. -function readString(obj: unknown, key: string): string { - if (Predicate.hasProperty(obj, key)) { - const value = obj[key]; - return typeof value === "string" ? value : ""; - } - return ""; -} - -function asRecord(obj: unknown): Record { - return Predicate.isObjectOrArray(obj) ? { ...obj } : {}; -} - /** * Type shape for the snippets-list response, used to drive `-o yaml|toml` key * casing. `description` (`nullable.Nullable[string]`) renders as @@ -92,32 +75,24 @@ const GO_SNIPPET_LIST = goStruct([ ], ]); -interface SnippetsResponseBody { - readonly data: ReadonlyArray; -} +const asRecord = (value: unknown): Record => + Predicate.isObjectOrArray(value) ? { ...value } : {}; -function parseSnippetsResponse(body: unknown): SnippetsResponseBody { - const data = Predicate.hasProperty(body, "data") && Array.isArray(body.data) ? body.data : []; - return { data }; -} +const decodeSnippetsList = Schema.decodeUnknownEffect( + operationDefinitions.v1ListAllSnippets.outputSchema, +); -function toSnippetRow(raw: unknown): SnippetRow { - const owner = Predicate.hasProperty(raw, "owner") ? raw.owner : undefined; - return { - id: readString(raw, "id"), - name: readString(raw, "name"), - visibility: readString(raw, "visibility"), - owner: { username: readString(owner, "username") }, - inserted_at: readString(raw, "inserted_at"), - updated_at: readString(raw, "updated_at"), - }; -} +const mapListError = mapHttpError({ + networkError: SnippetsListNetworkError, + statusError: SnippetsListUnexpectedStatusError, + networkMessage: (cause) => `failed to list snippets: ${cause}`, + statusMessage: (status, body) => `unexpected list snippets status ${status}: ${body}`, +}); export const snippetsList = Effect.fn("snippets.list")(function* (flags: SnippetsListFlags) { const output = yield* Output; const goOutputFlag = yield* OutputFlag; - const httpClient = yield* HttpClient.HttpClient; - const cliSettings = yield* CommandSettings; + const api = yield* CommandPlatformApi; const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; @@ -137,52 +112,33 @@ export const snippetsList = Effect.fn("snippets.list")(function* (flags: Snippet }); } - const tokenOpt = yield* resolveAccessToken; - const request = requestWithAuth( - HttpClientRequest.get(`${cliSettings.apiUrl}/v1/snippets`).pipe( - HttpClientRequest.setUrlParams({ project_ref: ref }), - ), - tokenOpt, - cliSettings.userAgent, - ); - const fetching = output.format === "text" ? yield* output.task("Fetching snippets...") : undefined; - const response = yield* httpClient.execute(request).pipe( - Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.mapError( - (cause) => - new SnippetsListNetworkError({ - message: `failed to list snippets: ${cause.reason.description ?? cause.reason._tag}`, - }), - ), - ); - - if (response.status !== 200) { - yield* fetching?.fail() ?? Effect.void; - const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - const body = sanitizeErrorBody(rawBody); - return yield* new SnippetsListUnexpectedStatusError({ - status: response.status, - body, - message: `unexpected list snippets status ${response.status}: ${body}`, - }); - } - + // `executeRaw` keeps the response body intact so JSON output emits it as the API returned + // it; the typed schema decodes a copy for rendering. + const response = yield* api + .executeRaw(operationDefinitions.v1ListAllSnippets, { project_ref: ref }) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.catch(mapListError), + Effect.tapError(() => fetching?.fail() ?? Effect.void), + ); const rawBody = yield* response.json.pipe( Effect.mapError( (cause) => new SnippetsListNetworkError({ message: `failed to list snippets: ${String(cause)}`, - // 200-response body decode failure — an API-response problem, not - // a transport/network failure. decode: true, }), ), + Effect.tapError(() => fetching?.fail() ?? Effect.void), + ); + const parsed = yield* decodeSnippetsList(rawBody).pipe( + Effect.catch(mapListError), + Effect.tapError(() => fetching?.fail() ?? Effect.void), ); yield* fetching?.clear ?? Effect.void; - const parsed = parseSnippetsResponse(rawBody); const goFmt = Option.getOrUndefined(goOutputFlag); if (goFmt === "json") { @@ -216,7 +172,18 @@ export const snippetsList = Effect.fn("snippets.list")(function* (flags: Snippet return; } - yield* output.raw(renderSnippetsTable(parsed.data.map(toSnippetRow))); + yield* output.raw( + renderSnippetsTable( + parsed.data.map((snippet): SnippetRow => ({ + id: snippet.id, + name: snippet.name, + visibility: snippet.visibility, + owner: { username: snippet.owner?.username ?? "" }, + inserted_at: snippet.inserted_at, + updated_at: snippet.updated_at, + })), + ), + ); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/snippets/list/list.integration.test.ts b/apps/cli/src/commands/snippets/list/list.integration.test.ts index 52d7e2eb48..fe15e0bec0 100644 --- a/apps/cli/src/commands/snippets/list/list.integration.test.ts +++ b/apps/cli/src/commands/snippets/list/list.integration.test.ts @@ -71,14 +71,13 @@ const EMPTY_RESPONSE: SnippetsResponse = { interface SetupOpts { format?: "text" | "json" | "stream-json"; goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; - // The handler consumes the raw JSON body (schema-bypass, see the handler's - // tolerant accessors), so tests may pass shapes the generated schema would - // reject — e.g. snippets without the `description` key. - response?: SnippetsResponse | { readonly data: ReadonlyArray> }; + response?: unknown; status?: number; network?: "fail"; } +const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-snippets-list-int-"); function setup(opts: SetupOpts = {}) { @@ -159,6 +158,82 @@ describe("snippets list integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("emits the raw response body, keys and unknown fields intact, under json", () => { + const body = { + cursor: "next-page", + data: [ + { + favorite: true, + id: SNIPPET_ID, + project: { name: "Proj", id: 1, ref: "extra-ref" }, + name: "Create table", + type: "sql", + visibility: "user", + description: null, + inserted_at: "2023-10-13T17:48:58.491Z", + updated_at: "2023-10-13T17:48:58.491Z", + owner: null, + updated_by: null, + }, + ], + }; + const { layer, out } = setup({ format: "json", response: body }); + return Effect.gen(function* () { + yield* snippetsList({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(toJson(success?.data)).toBe(toJson(body)); + }).pipe(Effect.provide(layer)); + }); + + it.effect("keeps an empty data array empty under json", () => { + const { layer, out } = setup({ format: "json", response: EMPTY_RESPONSE }); + return Effect.gen(function* () { + yield* snippetsList({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(toJson(success?.data)).toBe('{"data":[]}'); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "renders a snippet whose owner and updated_by are null with an empty owner cell", + () => { + const { layer, out } = setup({ + response: { data: [{ ...SNIPPET_BASE, owner: null, updated_by: null }] }, + }); + return Effect.gen(function* () { + yield* snippetsList({ projectRef: Option.none() }); + const row = out.stdoutText.split("\n").find((line) => line.includes(SNIPPET_ID)); + const cells = row?.split("|").map((cell) => cell.trim()); + expect(cells?.[1]).toBe("Create table"); + expect(cells?.[3]).toBe(""); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("renders a snippet with a visibility the contract does not list", () => { + const { layer, out } = setup({ + response: { data: [{ ...SNIPPET_BASE, visibility: "team" }] }, + }); + return Effect.gen(function* () { + yield* snippetsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("team"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails with a decode network error when the body does not match the contract", () => { + const { layer } = setup({ response: { data: [{ id: SNIPPET_ID }] } }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(snippetsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "SnippetsListNetworkError", + decode: true, + }); + } + }).pipe(Effect.provide(layer)); + }); + it.effect("emits a result event under --output-format=stream-json", () => { const { layer, out } = setup({ format: "stream-json" }); return Effect.gen(function* () { @@ -207,36 +282,11 @@ describe("snippets list integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("Go --output=toml emits Go-shaped bytes when no snippet has a description", () => { - // The Management API always includes `description`, so this success - // branch is unreachable in production — kept to pin the encoder bytes - // for the shape where the key is absent. - const { description: _omitted, ...withoutDescription } = SNIPPET_BASE; - const { layer, out } = setup({ - goOutput: "toml", - response: { data: [withoutDescription] }, - }); + it.effect("Go --output=toml encodes an empty snippet list", () => { + const { layer, out } = setup({ goOutput: "toml", response: EMPTY_RESPONSE }); return Effect.gen(function* () { yield* snippetsList({ projectRef: Option.none() }); - // PascalCase field names, sub-tables after primitives, 2-space indent. - expect(out.stdoutText).toBe(`[[Data]] - Favorite = false - Id = "${SNIPPET_ID}" - InsertedAt = "2023-10-13T17:48:58.491Z" - Name = "Create table" - Type = "sql" - UpdatedAt = "2023-10-13T17:48:58.491Z" - Visibility = "user" - [Data.Owner] - Id = 7.0 - Username = "supaseed" - [Data.Project] - Id = 1.0 - Name = "Proj" - [Data.UpdatedBy] - Id = 7.0 - Username = "supaseed" -`); + expect(out.stdoutText).toBe("Data = []\n"); }).pipe(Effect.provide(layer)); }); @@ -312,6 +362,16 @@ describe("snippets list integration", () => { name: "alpha", organization_id: "org-123", organization_slug: "acme", + id: VALID_REF, + region: "us-east-1", + created_at: "2026-01-01T00:00:00Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.example.com", + version: "15", + postgres_engine: "15", + release_channel: "ga", + }, }) : jsonResponse(request, 200, SINGLE_RESPONSE), ), diff --git a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md index 004ef3eef4..81dfa17e25 100644 --- a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md @@ -22,13 +22,17 @@ | Method | Path | Auth | Request body | Response (used fields) | | ------ | ---------------------------------------------- | ------------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -| `POST` | `/v1/projects/{ref}/config/auth/sso/providers` | Bearer token | `{type, metadata_xml?, metadata_url?, domains?, attribute_mapping?, name_id_format?}` | `{id, saml?, domains?, created_at?, updated_at?}` (parsed loosely) | +| `POST` | `/v1/projects/{ref}/config/auth/sso/providers` | Bearer token | `{type, metadata_xml?, metadata_url?, domains?, attribute_mapping?, name_id_format?}` | `{id, saml?, domains?, created_at?, updated_at?}` (typed schema) | | `GET` | `` | none | `Accept: application/xml`, 10s timeout | XML body (UTF-8) — validation when `--skip-url-validation` not set | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on 4xx | | `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate | -Bypasses the typed Management API client for the POST so user-supplied keys inside -`attribute_mapping.keys.` (e.g. `default`) are preserved verbatim. +The POST goes through the typed Management API client. `--attribute-mapping-file` is validated +against the API's `attribute_mapping` schema before the request: a key the schema does not model +fails with `failed to parse attribute mapping: Expected no excess property at [...]` and nothing +is sent. A 2xx response whose body does not match the provider schema fails as +`SsoAddNetworkError` (API-response classification). Transport failures read +`failed to create sso provider: `. ## Environment Variables @@ -50,10 +54,10 @@ Bypasses the typed Management API client for the POST so user-supplied keys insi | `1` | `SsoAddRequiredFlagError` — pflag consumed the `--type`/`-t` token as another flag's value (cobra `ValidateRequiredFlags`) | | `1` | `SsoMutexFlagError` — `--metadata-file` and `--metadata-url` both set | | `1` | `SsoAddMetadataFileError` — metadata file unreadable, non-UTF-8, or metadata URL invalid/unreachable/non-UTF-8 | -| `1` | `SsoAddAttributeMappingFileError` — JSON file unreadable or malformed | +| `1` | `SsoAddAttributeMappingFileError` — JSON file unreadable, malformed, or not matching the schema | | `1` | `SsoAddSamlDisabledError` — 404 from POST | | `1` | `SsoAddUnexpectedStatusError` — other non-2xx | -| `1` | `SsoAddNetworkError` — transport-level failure | +| `1` | `SsoAddNetworkError` — transport failure or undecodable 2xx body | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/sso/add/add.handler.ts b/apps/cli/src/commands/sso/add/add.handler.ts index 0606c5fe5a..10709a6f34 100644 --- a/apps/cli/src/commands/sso/add/add.handler.ts +++ b/apps/cli/src/commands/sso/add/add.handler.ts @@ -1,7 +1,8 @@ -import { Effect, Option, Predicate, Redacted, Stdio } from "effect"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import { operationDefinitions, type SupabaseApiError } from "@supabase/api/effect"; +import { Effect, Option, Predicate, type Redacted, Schema, Stdio, type Types } from "effect"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; @@ -12,20 +13,15 @@ import { pflagArgvScan, } from "../../../shared/cli/cobra-flag-groups.ts"; import { Output } from "../../../shared/output/output.service.ts"; -import { - encodeGoJson, - encodeGoStructJsonBody, -} from "../../../command-internal/go-output.encoders.ts"; +import { encodeGoJson } from "../../../command-internal/go-output.encoders.ts"; import { encodeGoToml, encodeGoYaml } from "../../../command-internal/go-struct-output.encoders.ts"; import { GO_SSO_PROVIDER_RESPONSE } from "../sso.go-payload.ts"; -import { sanitizeErrorBody } from "../../../command-internal/http-errors.ts"; -import { requestWithAuth } from "../../../command-internal/raw-http.ts"; -import { resolveAccessToken } from "../../../command-internal/resolve-token.ts"; +import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { accessTokenForProfile } from "../../../auth/command-credentials.layer.ts"; import { missingAccessTokenMessage } from "../../../auth/access-token.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; -import { suggestUpgrade } from "../../../command-internal/upgrade-suggest.ts"; +import { gateResponse, suggestUpgrade } from "../../../command-internal/upgrade-suggest.ts"; import { pflagBoolValue, pflagEnumValue, @@ -47,14 +43,34 @@ import { SsoAccessTokenError, SsoTomlEncodeError, } from "../sso.errors.ts"; -import { renderSingleProvider, toSsoProviderView } from "../sso.format.ts"; +import { renderSingleProvider } from "../sso.format.ts"; import { validateMetadataUrl } from "../sso.metadata-url.ts"; -import { SSO_NAME_ID_FORMATS, readAttributeMappingFile, readMetadataFile } from "../sso.saml.ts"; +import { + SSO_NAME_ID_FORMATS, + readAttributeMappingFile, + readMetadataFile, + toNameIdFormat, +} from "../sso.saml.ts"; import type { SsoAddFlags } from "./add.command.ts"; const SAML_DISABLED_MESSAGE = "SAML 2.0 support is not enabled for this project. Please enable it through the dashboard"; +type SsoAddBody = Types.Mutable< + Omit, "ref"> +>; + +const decodeProvider = Schema.decodeUnknownEffect( + operationDefinitions.v1CreateASsoProvider.outputSchema, +); + +const mapStatusOrNetwork = mapHttpError({ + networkError: SsoAddNetworkError, + statusError: SsoAddUnexpectedStatusError, + networkMessage: (cause) => `failed to create sso provider: ${cause}`, + statusMessage: (_status, body) => `Unexpected error adding identity provider: ${body}`, +}); + const readMetadata = readMetadataFile({ openError: (args) => new SsoAddMetadataFileError(args), nonUtf8Error: (args) => @@ -65,6 +81,43 @@ const readAttributeMapping = readAttributeMappingFile({ openError: (args) => new SsoAddAttributeMappingFileError(args), }); +type TokenForAux = Effect.Effect> | undefined, never, R>; + +const handleAddError = ( + ref: string, + apiUrl: string, + tokenForAux: TokenForAux, + cause: SupabaseApiError, +) => + mapStatusOrNetwork(cause).pipe( + Effect.catchTag("SsoAddUnexpectedStatusError", (mapped) => + Effect.gen(function* () { + const upgradeSuggested = yield* suggestUpgrade({ + projectRef: ref, + featureKey: "auth.saml_2", + statusCode: mapped.status, + response: gateResponse(cause), + apiUrl, + ...(yield* Effect.map(tokenForAux, (token) => + token !== undefined ? { accessToken: token } : {}, + )), + }); + if (mapped.status === 404) { + return yield* new SsoAddSamlDisabledError({ + message: SAML_DISABLED_MESSAGE, + upgradeSuggested, + }); + } + return yield* new SsoAddUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }); + }), + ), + ); + const SSO_ADD_COMMAND_PATH = ["sso", "add"] as const; // Declaration order sets the mutex error message's bracket order. @@ -90,7 +143,7 @@ const SSO_ADD_SCAN_SPEC = { export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { const output = yield* Output; const goOutputFlag = yield* OutputFlag; - const httpClient = yield* HttpClient.HttpClient; + const api = yield* CommandPlatformApi; const cliSettings = yield* CommandSettings; const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; @@ -195,15 +248,13 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { }); yield* Effect.gen(function* () { - // Posted as raw JSON, not the generated schema, so unlisted - // `attribute_mapping.keys.` fields (e.g. `default`) survive. - const body: Record = { - type: flags.type, + const body: SsoAddBody = { + type: "saml", }; if (Option.isSome(metadataFile)) { const xml = yield* readMetadata(metadataFile.value); - body["metadata_xml"] = xml; + body.metadata_xml = xml; } else if (Option.isSome(metadataUrl)) { if (!skipUrlValidation) { yield* validateMetadataUrl(metadataUrl.value).pipe( @@ -217,104 +268,72 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { ), ); } - body["metadata_url"] = metadataUrl.value; + body.metadata_url = metadataUrl.value; } if (Option.isSome(attributeMappingFile)) { const mapping = yield* readAttributeMapping(attributeMappingFile.value); - body["attribute_mapping"] = mapping; + body.attribute_mapping = mapping; } if (domains.length > 0) { - body["domains"] = [...domains]; + body.domains = [...domains]; } if (Option.isSome(nameIdFormat)) { - body["name_id_format"] = nameIdFormat.value; + const format = toNameIdFormat(nameIdFormat.value); + if (format !== undefined) body.name_id_format = format; } - const creating = - output.format === "text" ? yield* output.task("Adding SSO provider...") : undefined; - - const tokenOpt = - reconciledTokenCached !== undefined - ? yield* Effect.filterOrFail( + const reconciledToken = + reconciledTokenCached === undefined + ? undefined + : (yield* Effect.filterOrFail( reconciledTokenCached, Option.isSome, () => new SsoAccessTokenError({ message: missingAccessTokenMessage() }), - ) - : yield* resolveAccessToken; - - // `bearerToken(Redacted)` keeps the Authorization header's redaction - // marker, so any future debug serialization of the request stays - // opaque about the token. - const request = requestWithAuth( - HttpClientRequest.post(`${apiUrl}/v1/projects/${ref}/config/auth/sso/providers`), - tokenOpt, - cliSettings.userAgent, - ).pipe( - // Alphabetical key order so the cli-e2e replay server's - // string-compare body match succeeds. - HttpClientRequest.bodyText(encodeGoStructJsonBody(body), "application/json"), - ); + )).value; + + const creating = + output.format === "text" ? yield* output.task("Adding SSO provider...") : undefined; - const response = yield* httpClient.execute(request).pipe( + // `executeRaw` keeps the response body intact so structured output emits it as the API + // returned it; the typed schema decodes a copy for the table view. + const { rawBody, provider } = yield* Effect.gen(function* () { + const response = yield* api + .executeRaw( + operationDefinitions.v1CreateASsoProvider, + { ref, ...body }, + { + retry: { requestTimeoutMs: Number.POSITIVE_INFINITY }, + ...(reconciledToken === undefined + ? {} + : { baseUrl: apiUrl, accessToken: reconciledToken }), + }, + ) + .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); + const rawBody: unknown = yield* response.json; + return { rawBody, provider: yield* decodeProvider(rawBody) }; + }).pipe( + Effect.catch((cause) => handleAddError(ref, apiUrl, reconciledTokenForAux, cause)), Effect.tapError(() => creating?.fail() ?? Effect.void), - Effect.mapError( - (cause) => - new SsoAddNetworkError({ - message: `failed to create sso provider: ${String(cause)}`, - }), - ), ); - - if (response.status !== 201) { - const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - // Same cap + control-character sanitization as the typed-client - // error mapper (`mapHttpError`), so this raw-HTTP path doesn't skip it. - const bodyText = sanitizeErrorBody(rawBody); - const upgradeSuggested = yield* suggestUpgrade({ - projectRef: ref, - featureKey: "auth.saml_2", - statusCode: response.status, - response, - apiUrl, - ...(yield* Effect.map(reconciledTokenForAux, (token) => - token !== undefined ? { accessToken: token } : {}, - )), - }); - yield* creating?.fail() ?? Effect.void; - if (response.status === 404) { - return yield* new SsoAddSamlDisabledError({ - message: SAML_DISABLED_MESSAGE, - upgradeSuggested, - }); - } - return yield* new SsoAddUnexpectedStatusError({ - status: response.status, - body: bodyText, - message: `Unexpected error adding identity provider: ${bodyText}`, - upgradeSuggested, - }); - } - - const parsedJson = yield* response.json.pipe(Effect.orElseSucceed((): unknown => ({}))); yield* creating?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); if (goFmt === "json") { - yield* output.raw(encodeGoJson(parsedJson)); + yield* output.raw(encodeGoJson(rawBody)); return; } if (goFmt === "yaml") { - yield* output.raw(encodeGoYaml(parsedJson, GO_SSO_PROVIDER_RESPONSE)); + yield* output.raw(encodeGoYaml(rawBody, GO_SSO_PROVIDER_RESPONSE)); return; } if (goFmt === "toml") { // Same TOML-encode-failure pattern as list/show. const toml = yield* Effect.try({ - try: () => encodeGoToml(parsedJson, GO_SSO_PROVIDER_RESPONSE), + try: () => encodeGoToml(rawBody, GO_SSO_PROVIDER_RESPONSE), catch: (cause) => new SsoTomlEncodeError({ message: `failed to output toml: ${cause instanceof Error ? cause.message : String(cause)}`, @@ -331,12 +350,12 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { if (output.format === "json" || output.format === "stream-json") { yield* output.success( "", - Predicate.isObjectOrArray(parsedJson) ? { ...parsedJson } : { value: parsedJson }, + Predicate.isObjectOrArray(rawBody) ? { ...rawBody } : { value: rawBody }, ); return; } - yield* output.raw(renderSingleProvider(toSsoProviderView(parsedJson))); + yield* output.raw(renderSingleProvider(provider)); }).pipe( // Linked-project cache fill GETs `/v1/projects/{ref}` through the // reconciled host, never the config layer's. diff --git a/apps/cli/src/commands/sso/add/add.integration.test.ts b/apps/cli/src/commands/sso/add/add.integration.test.ts index be7cb4b06c..1f111862bd 100644 --- a/apps/cli/src/commands/sso/add/add.integration.test.ts +++ b/apps/cli/src/commands/sso/add/add.integration.test.ts @@ -870,6 +870,17 @@ describe("sso add integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("Go --output=json keeps response keys outside the OpenAPI schema", () => { + const { layer, out } = setup({ + goOutput: "json", + body: { ...RESPONSE_PROVIDER, resource_id: "res-123" }, + }); + return Effect.gen(function* () { + yield* ssoAdd(defaultFlags); + expect(out.stdoutText).toMatch(/"resource_id":\s*"res-123"/); + }).pipe(Effect.provide(layer)); + }); + it.effect("TS --output-format=json emits success", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { @@ -905,7 +916,7 @@ describe("sso add integration", () => { if (Exit.isFailure(exit)) { const dump = Cause.pretty(exit.cause); expect(dump).toContain("SsoAddUnexpectedStatusError"); - expect(dump).toContain("Unexpected error adding identity provider"); + expect(dump).toContain('Unexpected error adding identity provider: {"error":"boom"}'); } }).pipe(Effect.provide(layer)); }); @@ -1026,6 +1037,52 @@ describe("sso add integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("an attribute mapping with an unknown key fails before the POST", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const path = pathService.join(tempRoot.current, "unknown-key.json"); + yield* fs.writeFileString(path, '{"keys":{"email":{"nam":"email"}}}'); + const flags = { ...defaultFlags, attributeMappingFile: Option.some(path) }; + const { layer, api } = setup({ cliArgs: cliArgsFor(flags) }); + return yield* Effect.gen(function* () { + const exit = yield* Effect.exit(ssoAdd(flags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = Cause.pretty(exit.cause); + expect(dump).toContain("SsoAddAttributeMappingFileError"); + expect(dump).toContain("failed to parse attribute mapping: Expected no excess property"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("a transport failure surfaces as a network error naming the operation", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(ssoAdd(defaultFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = Cause.pretty(exit.cause); + expect(dump).toContain("SsoAddNetworkError"); + expect(dump).toContain("failed to create sso provider:"); + } + }).pipe(Effect.provide(layer)); + }); + + it.effect("a 201 body that fails the provider schema surfaces as an API response error", () => { + const { layer } = setup({ body: { saml: {} } }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(ssoAdd(defaultFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const classified = classifyCliCauseActionability(exit.cause); + expect(classified.error_fingerprint).toBe("tag:SsoAddNetworkError:api_response"); + } + }).pipe(Effect.provide(layer)); + }); + const writeProfileYaml = Effect.fnUntraced(function* (name: string, apiUrl: string) { const fs = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; diff --git a/apps/cli/src/commands/sso/sso.errors.ts b/apps/cli/src/commands/sso/sso.errors.ts index 3421bdaea4..a826f877bb 100644 --- a/apps/cli/src/commands/sso/sso.errors.ts +++ b/apps/cli/src/commands/sso/sso.errors.ts @@ -93,9 +93,12 @@ export class SsoListUnexpectedStatusError extends Data.TaggedError("SsoListUnexp export class SsoAddNetworkError extends Data.TaggedError("SsoAddNetworkError")<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/commands/sso/sso.format.ts b/apps/cli/src/commands/sso/sso.format.ts index 73a5711fa5..485b169ab7 100644 --- a/apps/cli/src/commands/sso/sso.format.ts +++ b/apps/cli/src/commands/sso/sso.format.ts @@ -8,10 +8,8 @@ export const quoteSsoString = Schema.encodeSync(Schema.fromJsonString(Schema.Str const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** - * Permissive shape that covers both the typed-client output - * (`V1GetASsoProviderOutput` / list items) and arbitrary JSON returned by the - * raw POST/PUT path. `attribute_mapping` is left as `unknown` so we can render - * provider responses that carry user-defined keys. + * Provider shape rendered by list/show/add/update/remove, structurally + * compatible with the typed-client provider outputs. */ export interface SsoProviderView { readonly id: string; @@ -31,61 +29,6 @@ export interface SsoProviderView { readonly updated_at?: string; } -/** - * Defensive extraction for the raw-HTTP add/update path. The response is - * untyped (we bypass the generated schema to preserve `attribute_mapping.keys..default`), - * so the formatter coerces an arbitrary object into the provider view shape - * without throwing on missing fields. - */ -export function toSsoProviderView(value: unknown): SsoProviderView { - const root = fieldsOf(value); - if (root === undefined) { - return { id: "" }; - } - const saml = fieldsOf(root["saml"]); - - const domainsRaw = root["domains"]; - const domains = Array.isArray(domainsRaw) - ? domainsRaw.flatMap((item) => { - const d = fieldsOf(item); - return d === undefined - ? [] - : [ - { - domain: typeof d["domain"] === "string" ? d["domain"] : undefined, - created_at: typeof d["created_at"] === "string" ? d["created_at"] : undefined, - updated_at: typeof d["updated_at"] === "string" ? d["updated_at"] : undefined, - }, - ]; - }) - : undefined; - - return { - id: typeof root["id"] === "string" ? root["id"] : "", - saml: - saml === undefined - ? undefined - : { - entity_id: typeof saml["entity_id"] === "string" ? saml["entity_id"] : undefined, - metadata_url: - typeof saml["metadata_url"] === "string" ? saml["metadata_url"] : undefined, - metadata_xml: - typeof saml["metadata_xml"] === "string" ? saml["metadata_xml"] : undefined, - name_id_format: - typeof saml["name_id_format"] === "string" ? saml["name_id_format"] : undefined, - attribute_mapping: saml["attribute_mapping"], - }, - domains, - created_at: typeof root["created_at"] === "string" ? root["created_at"] : undefined, - updated_at: typeof root["updated_at"] === "string" ? root["updated_at"] : undefined, - }; -} - -function fieldsOf(value: unknown): { readonly [key: string]: unknown } | undefined { - if (!Predicate.isObjectOrArray(value)) return undefined; - return Array.isArray(value) ? {} : value; -} - /** * Validates a positional provider-id argument as a canonical UUID. * Failure message uses `%q`-style quoting (JSON.stringify wraps the raw input). diff --git a/apps/cli/src/commands/sso/sso.format.unit.test.ts b/apps/cli/src/commands/sso/sso.format.unit.test.ts index 202a521c30..3b2922fd80 100644 --- a/apps/cli/src/commands/sso/sso.format.unit.test.ts +++ b/apps/cli/src/commands/sso/sso.format.unit.test.ts @@ -15,7 +15,6 @@ import { renderInfoMarkdown, renderListProviders, renderSingleProvider, - toSsoProviderView, validateUuid, } from "./sso.format.ts"; @@ -243,18 +242,3 @@ describe("formatSsoMetadataXml (xmlfmt parity port)", () => { expect(formatSsoMetadataXml(saml, " ", " ")).toBe(expected); }); }); - -describe("toSsoProviderView coercion", () => { - it("returns an empty view for non-object inputs", () => { - expect(toSsoProviderView(null).id).toBe(""); - expect(toSsoProviderView("string").id).toBe(""); - }); - - it("preserves arbitrary attribute_mapping data (incl. user keys)", () => { - const view = toSsoProviderView({ - id: "abc", - saml: { attribute_mapping: { keys: { a: { default: 3 } } } }, - }); - expect(view.saml?.attribute_mapping).toEqual({ keys: { a: { default: 3 } } }); - }); -}); diff --git a/apps/cli/src/commands/sso/sso.saml.ts b/apps/cli/src/commands/sso/sso.saml.ts index 5dc5cac037..121d96f03d 100644 --- a/apps/cli/src/commands/sso/sso.saml.ts +++ b/apps/cli/src/commands/sso/sso.saml.ts @@ -1,4 +1,5 @@ -import { Effect, FileSystem, Predicate } from "effect"; +import { operationDefinitions } from "@supabase/api/effect"; +import { Effect, FileSystem, Predicate, Schema, SchemaIssue } from "effect"; import type { PlatformError } from "effect/PlatformError"; import { quoteSsoString } from "./sso.format.ts"; @@ -16,6 +17,8 @@ function fileErrorReason(cause: PlatformError): SsoFileErrorReason { return "other"; } +export type SsoNameIdFormat = (typeof SSO_NAME_ID_FORMATS)[number]; + /** * The `--name-id-format` value set, shared by `sso add` and `sso update`. * Order matters: it drives the CLI help text and is joined verbatim into @@ -29,6 +32,10 @@ export const SSO_NAME_ID_FORMATS = [ "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", ] as const; +/** Narrows a pflag-validated `--name-id-format` value to the API's literal union. */ +export const toNameIdFormat = (value: string): SsoNameIdFormat | undefined => + SSO_NAME_ID_FORMATS.find((format) => format === value); + /** * Validates that raw bytes decode as strict UTF-8. Using * `TextDecoder("utf-8", { fatal: true })` makes any malformed surrogate or @@ -82,10 +89,16 @@ export const readMetadataFile = return new TextDecoder("utf-8").decode(bytes); }); +const attributeMappingSchema = + operationDefinitions.v1CreateASsoProvider.inputSchema.fields.attribute_mapping; +const decodeAttributeMapping = Schema.decodeUnknownEffect(attributeMappingSchema); +const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); + +export type SsoAttributeMapping = Schema.Schema.Type; + /** - * Reads an attribute mapping JSON file. Returns the parsed value as - * `unknown` so the raw POST/PUT payload preserves user-defined keys like - * `default` that aren't in the generated `attribute_mapping` schema. + * Reads an attribute mapping JSON file and validates it against the Management + * API `attribute_mapping` schema; keys the API does not model are rejected. */ export const readAttributeMappingFile = (factory: { @@ -94,7 +107,7 @@ export const readAttributeMappingFile = readonly reason: SsoFileErrorReason; }) => E; }) => - (path: string): Effect.Effect => + (path: string): Effect.Effect => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const content = yield* fs.readFileString(path).pipe( @@ -114,5 +127,15 @@ export const readAttributeMappingFile = reason: "invalid_content", }), }); - return parsed; + return yield* decodeAttributeMapping(parsed, { + onExcessProperty: "error", + errors: "all", + }).pipe( + Effect.mapError((cause) => + factory.openError({ + message: `failed to parse attribute mapping: ${formatSchemaIssue(cause.issue).replace(/\s*\n\s*/g, " ")}`, + reason: "invalid_content", + }), + ), + ); }); diff --git a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md index 9342160b23..7316e098db 100644 --- a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md @@ -23,14 +23,17 @@ | Method | Path | Auth | Request body | Response (used fields) | | ------ | ------------------------------------------------------------ | ------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------ | | `GET` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | -| `PUT` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | `{metadata_xml?, metadata_url?, domains, attribute_mapping?, name_id_format?}` | `{id, saml?, domains?, ...}` (parsed loosely) | +| `PUT` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | `{metadata_xml?, metadata_url?, domains, attribute_mapping?, name_id_format?}` | `{id, saml?, domains?, ...}` (typed schema) | | `GET` | `` | none | `Accept: application/xml`, 10s timeout | XML body (UTF-8) — validation when `--skip-url-validation` not set | | `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on 4xx | | `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate | -Bypasses the typed Management API client for the PUT so user-supplied keys inside -`attribute_mapping.keys.` (e.g. `default`) are preserved verbatim. The initial -GET still uses the typed client. +Both the GET and the PUT go through the typed Management API client (the PUT retries on 5xx and +transport failures like any idempotent request). `--attribute-mapping-file` is validated against +the API's `attribute_mapping` schema after the GET and before the PUT: a key the schema does not +model fails with `failed to parse attribute mapping: Expected no excess property at [...]`. A 2xx +body that does not match the provider schema fails as `SsoUpdateNetworkError` (API-response +classification), for the GET before any PUT. ## Environment Variables @@ -53,10 +56,10 @@ GET still uses the typed client. | `1` | `SsoInvalidUuidError` — provider ID is not a canonical UUID | | `1` | `SsoMutexFlagError` — flag combinations: `--domains` with `--add/--remove-domains`, or `--metadata-file` with `--metadata-url` | | `1` | `SsoUpdateMetadataFileError` — metadata file unreadable, non-UTF-8, or metadata URL invalid/unreachable/non-UTF-8 | -| `1` | `SsoUpdateAttributeMappingFileError` — JSON file unreadable or malformed | +| `1` | `SsoUpdateAttributeMappingFileError` — JSON file unreadable, malformed, or not matching the schema | | `1` | `SsoUpdateNotFoundError` — 404 from GET | | `1` | `SsoUpdateUnexpectedStatusError` — non-2xx from GET or PUT | -| `1` | `SsoUpdateNetworkError` — transport-level failure | +| `1` | `SsoUpdateNetworkError` — transport failure or undecodable 2xx body | ## Telemetry Events Fired @@ -90,7 +93,7 @@ Single `success` event with the parsed response as data. - Flag values follow pflag's consumption rules, not the TS parser's: every value the handler acts on (`--project-ref`, `--metadata-file`, `--metadata-url`, `--attribute-mapping-file`, the three domain slices, `--name-id-format`, `--skip-url-validation`) is reconciled against a pflag-faithful raw-argv scan — same mechanism as `sso add` (CLI-1982). Repeated flags resolve last-wins (pflag Sets every occurrence; the TS parser is first-wins), and an occurrence pflag's `Value.Set` would reject — a boolean outside the accepted spellings (`--skip-url-validation=yes`), or a `--name-id-format` outside the enum — fails with pflag's exact `invalid argument …` message before any validation or request. - Positional arity follows pflag too: the exact-one-argument rule is re-counted over pflag-effective positionals (arity validation runs before every hook and flag validation), so a consumed flag token that orphans its parser-value (`--domains --metadata-url u `) fails with the exact `accepts 1 arg(s), received 2` before the GET — and wins over both the mutex and invalid-UUID errors. - The workdir follows pflag/viper too: the workdir-change step chdir's to the effective `--workdir` (last occurrence, even a flag-shaped consumed token like `--workdir --metadata-file`) or `SUPABASE_WORKDIR`, and a missing directory aborts with the exact `failed to change workdir: chdir …` after the arity check but before the mutex checks and any request. A changed-but-empty `--workdir=` shadows the env var and falls back to the always-valid project-root walk-up, exactly like viper. -- The profile follows pflag/viper too (PR #5974 round 7): whenever the pflag-effective `--profile`/`SUPABASE_PROFILE` token differs from the one the Effect parser gave the config layer (repeats — pflag is last-wins where the parser is first-wins, so `--profile a.yml --profile b.yml` GETs and PUTs `b.yml`'s `api_url`; a flag-shaped consumed value — `--profile --add-domains`; an explicit `--profile supabase` shadowing the env; an untrimmed/empty persisted `~/.supabase/profile` file), the handler re-runs the profile loader on the effective token (`profile-load.ts`). Both the initial GET and the PUT then target that profile's `api_url` — the GET is issued through the raw HTTP client because the typed client bakes the layer's `api_url` in at construction — and a token that can't be loaded aborts with the matching error (`failed to read profile: …` / `failed to parse profile: …` / `invalid profile: …`, byte-exact for the deterministic classes) after the arity check, before the workdir and mutex checks and any request. The raw GET mirrors the old typed client's behavior on the 200 path too: an undecodable JSON body aborts with `failed to get sso provider: ` before any PUT (detail text is JS `JSON.parse`'s — micro-divergence), a 200 without a JSON content type falls into the gate + unexpected-status branch, and the response is stitched through the shared per-command identity guard exactly like the typed client. The upgrade-gate fallback GETs and the linked-project cache fill also target the reconciled host. Where the scan and the parser agree — every normal invocation — the config layer's resolution (including its pre-existing lenient missing/malformed-file fallback, which predates CLI-1982 and applies shell-wide) is used unchanged, via the typed client. +- The profile follows pflag/viper too (PR #5974 round 7): whenever the pflag-effective `--profile`/`SUPABASE_PROFILE` token differs from the one the Effect parser gave the config layer (repeats — pflag is last-wins where the parser is first-wins, so `--profile a.yml --profile b.yml` GETs and PUTs `b.yml`'s `api_url`; a flag-shaped consumed value — `--profile --add-domains`; an explicit `--profile supabase` shadowing the env; an untrimmed/empty persisted `~/.supabase/profile` file), the handler re-runs the profile loader on the effective token (`profile-load.ts`). Both the initial GET and the PUT then target that profile's `api_url` (a per-request base URL and token on the typed client), and a token that can't be loaded aborts with the matching error (`failed to read profile: …` / `failed to parse profile: …` / `invalid profile: …`, byte-exact for the deterministic classes) after the arity check, before the workdir and mutex checks and any request. The upgrade-gate fallback GETs and the linked-project cache fill also target the reconciled host. Where the scan and the parser agree — every normal invocation — the config layer's resolution (including its pre-existing lenient missing/malformed-file fallback, which predates CLI-1982 and applies shell-wide) is used unchanged. - Accepted micro-divergences of the profile emulation (each fail-closed: both CLIs exit 1 with zero requests; only stderr detail can differ): YAML parse-failure detail text (JS `yaml` vs go-yaml, shared `failed to read profile: While parsing config: ` prefix); non-YAML/JSON viper config types (`.toml`, `.env`, …) parsed as YAML; `http_url`/`hostname_rfc1123`/`uuid4` validator tags approximated; the final line of a padded multi-line error loses its trailing spaces to the shared error normalizer's trim. Also: when the effective and layer profiles differ AND the token is keyring-relevant, the keyring token lookup still uses the layer profile's name (env-token flows, e.g. the cli-e2e harness, are unaffected), and the upgrade-suggestion billing URL keeps the layer profile's dashboard host. - Always performs the GET pre-check, regardless of whether `--add-domains` / `--remove-domains` are used. - Domain merge: removals are applied first, then additions. The result is **unordered**; consumers must sort if comparing. diff --git a/apps/cli/src/commands/sso/update/update.handler.ts b/apps/cli/src/commands/sso/update/update.handler.ts index d50ee832de..042fca0962 100644 --- a/apps/cli/src/commands/sso/update/update.handler.ts +++ b/apps/cli/src/commands/sso/update/update.handler.ts @@ -1,11 +1,9 @@ -import type { SupabaseApiError } from "@supabase/api/effect"; -import { Effect, Option, Predicate, Redacted, Stdio } from "effect"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import { operationDefinitions, type SupabaseApiError } from "@supabase/api/effect"; +import { Effect, Option, Predicate, type Redacted, Schema, Stdio, type Types } from "effect"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; -import { IdentityStitch } from "../../../command-internal/identity-stitch.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; import { @@ -15,15 +13,10 @@ import { pflagArgvScan, } from "../../../shared/cli/cobra-flag-groups.ts"; import { Output } from "../../../shared/output/output.service.ts"; -import { - encodeGoJson, - encodeGoStructJsonBody, -} from "../../../command-internal/go-output.encoders.ts"; +import { encodeGoJson } from "../../../command-internal/go-output.encoders.ts"; import { encodeGoToml, encodeGoYaml } from "../../../command-internal/go-struct-output.encoders.ts"; import { GO_SSO_PROVIDER_RESPONSE } from "../sso.go-payload.ts"; -import { mapHttpError, sanitizeErrorBody } from "../../../command-internal/http-errors.ts"; -import { requestWithAuth } from "../../../command-internal/raw-http.ts"; -import { resolveAccessToken } from "../../../command-internal/resolve-token.ts"; +import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { accessTokenForProfile } from "../../../auth/command-credentials.layer.ts"; import { missingAccessTokenMessage } from "../../../auth/access-token.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; @@ -50,14 +43,14 @@ import { SsoAccessTokenError, SsoTomlEncodeError, } from "../sso.errors.ts"; -import { - quoteSsoString, - renderSingleProvider, - toSsoProviderView, - validateUuid, -} from "../sso.format.ts"; +import { quoteSsoString, renderSingleProvider, validateUuid } from "../sso.format.ts"; import { validateMetadataUrl } from "../sso.metadata-url.ts"; -import { SSO_NAME_ID_FORMATS, readAttributeMappingFile, readMetadataFile } from "../sso.saml.ts"; +import { + SSO_NAME_ID_FORMATS, + readAttributeMappingFile, + readMetadataFile, + toNameIdFormat, +} from "../sso.saml.ts"; import type { SsoUpdateFlags } from "./update.command.ts"; const readMetadata = readMetadataFile({ @@ -70,6 +63,17 @@ const readAttributeMapping = readAttributeMappingFile({ openError: (args) => new SsoUpdateAttributeMappingFileError(args), }); +type SsoUpdateBody = Types.Mutable< + Omit< + Schema.Schema.Type, + "ref" | "provider_id" + > +>; + +const decodeProvider = Schema.decodeUnknownEffect( + operationDefinitions.v1UpdateASsoProvider.outputSchema, +); + const mapGetStatusOrNetwork = mapHttpError({ networkError: SsoUpdateNetworkError, statusError: SsoUpdateUnexpectedStatusError, @@ -77,6 +81,14 @@ const mapGetStatusOrNetwork = mapHttpError({ statusMessage: (_status, body) => `unexpected error fetching identity provider: ${body}`, }); +const mapPutStatusOrNetwork = mapHttpError({ + networkError: SsoUpdateNetworkError, + statusError: SsoUpdateUnexpectedStatusError, + networkMessage: (cause) => `failed to update sso provider: ${cause}`, + // Go reuses the GET error message for the PUT. + statusMessage: (_status, body) => `unexpected error fetching identity provider: ${body}`, +}); + const SSO_UPDATE_COMMAND_PATH = ["sso", "update"] as const; /** @@ -111,16 +123,32 @@ const SSO_UPDATE_SCAN_SPEC = { valueFlagShorthands: PERSISTENT_VALUE_FLAG_SHORTHANDS, } as const; -const handleGetError = (ref: string, providerId: string, cause: SupabaseApiError) => +type TokenForAux = Effect.Effect> | undefined, never, R>; + +interface GateContext { + readonly ref: string; + readonly apiUrl: string; + readonly tokenForAux: TokenForAux; +} + +const suggestUpgradeFor = (gate: GateContext, cause: SupabaseApiError, statusCode: number) => + Effect.gen(function* () { + const token = yield* gate.tokenForAux; + return yield* suggestUpgrade({ + projectRef: gate.ref, + featureKey: "auth.saml_2", + statusCode, + response: gateResponse(cause), + apiUrl: gate.apiUrl, + ...(token !== undefined ? { accessToken: token } : {}), + }); + }); + +const handleGetError = (gate: GateContext, providerId: string, cause: SupabaseApiError) => mapGetStatusOrNetwork(cause).pipe( Effect.catchTag("SsoUpdateUnexpectedStatusError", (mapped) => Effect.gen(function* () { - const upgradeSuggested = yield* suggestUpgrade({ - projectRef: ref, - featureKey: "auth.saml_2", - statusCode: mapped.status, - response: gateResponse(cause), - }); + const upgradeSuggested = yield* suggestUpgradeFor(gate, cause, mapped.status); if (mapped.status === 404) { return yield* new SsoUpdateNotFoundError({ message: `An identity provider with ID ${quoteSsoString(providerId)} could not be found.`, @@ -137,31 +165,25 @@ const handleGetError = (ref: string, providerId: string, cause: SupabaseApiError ), ); +const handlePutError = (gate: GateContext, cause: SupabaseApiError) => + mapPutStatusOrNetwork(cause).pipe( + Effect.catchTag("SsoUpdateUnexpectedStatusError", (mapped) => + Effect.gen(function* () { + const upgradeSuggested = yield* suggestUpgradeFor(gate, cause, mapped.status); + return yield* new SsoUpdateUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }); + }), + ), + ); + interface ExistingDomainItem { readonly domain?: string; } -/** - * Narrows a raw GET-provider JSON body to the `domains` shape `mergeDomains` - * consumes. - */ -function extractDomainItems(parsed: unknown): ReadonlyArray | undefined { - if (!Predicate.hasProperty(parsed, "domains")) { - return undefined; - } - const domains = parsed.domains; - if (!Array.isArray(domains)) { - return undefined; - } - return domains.map((item): ExistingDomainItem => { - if (!Predicate.hasProperty(item, "domain")) { - return {}; - } - const domain = item.domain; - return typeof domain === "string" ? { domain } : {}; - }); -} - function mergeDomains( existing: ReadonlyArray | undefined, add: ReadonlyArray, @@ -187,12 +209,10 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag const output = yield* Output; const goOutputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const httpClient = yield* HttpClient.HttpClient; const cliSettings = yield* CommandSettings; const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const identityStitch = yield* Effect.serviceOption(IdentityStitch); const stdio = yield* Stdio.Stdio; const rawArgs = yield* stdio.args; @@ -297,107 +317,36 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag }); yield* Effect.gen(function* () { + const reconciledToken = + reconciledTokenCached === undefined + ? undefined + : (yield* Effect.filterOrFail( + reconciledTokenCached, + Option.isSome, + () => new SsoAccessTokenError({ message: missingAccessTokenMessage() }), + )).value; + const requestOptions = + reconciledToken === undefined + ? undefined + : { baseUrl: apiUrl, accessToken: reconciledToken }; + const gate = { ref, apiUrl, tokenForAux: reconciledTokenForAux }; + const fetching = output.format === "text" ? yield* output.task("Updating SSO provider...") : undefined; - // The typed client bakes the layer's apiUrl in at construction, so when - // the reconciled profile differs, the GET must be issued raw against - // the effective host, matching the PUT's host. Error mapping and the - // spinner/suggestion ordering mirror the typed path (`handleGetError`). - const rawGetProvider = Effect.gen(function* () { - const tokenOpt = - reconciledTokenCached !== undefined - ? yield* Effect.filterOrFail( - reconciledTokenCached, - Option.isSome, - () => new SsoAccessTokenError({ message: missingAccessTokenMessage() }), - ) - : yield* resolveAccessToken; - const request = requestWithAuth( - HttpClientRequest.get( - `${apiUrl}/v1/projects/${ref}/config/auth/sso/providers/${providerId}`, - ), - tokenOpt, - cliSettings.userAgent, - ); - const response = yield* httpClient.execute(request).pipe( - Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.mapError( - (cause) => - new SsoUpdateNetworkError({ - message: `failed to get sso provider: ${String(cause)}`, - }), - ), - ); - // Every Management API response goes through the identity stitch - // once per command; this raw GET must too, before the status gate. - // `serviceOption`: absent outside the real CLI tree (handler-level - // tests), where no telemetry runtime exists to stitch into. - if (Option.isSome(identityStitch)) { - yield* identityStitch.value.stitch(response); - } - const rawBody = yield* response.text.pipe( + // Always GETs first, regardless of which flags are set. + const existing = yield* api.v1 + .getASsoProvider({ ref, provider_id: providerId }, requestOptions) + .pipe( Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.mapError( - (cause) => - new SsoUpdateNetworkError({ - message: `failed to get sso provider: ${String(cause)}`, - }), - ), + Effect.catch((cause) => handleGetError(gate, providerId, cause)), ); - const contentType = response.headers["content-type"] ?? ""; - if (response.status === 200 && contentType.includes("json")) { - const parsed = yield* Effect.try({ - // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- Native parser errors are CLI output; schema decoding discards their messages. - try: (): unknown => JSON.parse(rawBody), - catch: (cause) => - new SsoUpdateNetworkError({ - message: `failed to get sso provider: ${cause instanceof Error ? cause.message : String(cause)}`, - decode: true, - }), - }).pipe(Effect.tapError(() => fetching?.fail() ?? Effect.void)); - return { domains: extractDomainItems(parsed) }; - } - // A 200 without a JSON content type falls into this branch too. - yield* fetching?.fail() ?? Effect.void; - const bodyText = sanitizeErrorBody(rawBody); - const upgradeSuggested = yield* suggestUpgrade({ - projectRef: ref, - featureKey: "auth.saml_2", - statusCode: response.status, - response, - apiUrl, - ...(yield* Effect.map(reconciledTokenForAux, (token) => - token !== undefined ? { accessToken: token } : {}, - )), - }); - if (response.status === 404) { - return yield* new SsoUpdateNotFoundError({ - message: `An identity provider with ID ${quoteSsoString(providerId)} could not be found.`, - upgradeSuggested, - }); - } - return yield* new SsoUpdateUnexpectedStatusError({ - status: response.status, - body: bodyText, - message: `unexpected error fetching identity provider: ${bodyText}`, - upgradeSuggested, - }); - }); - // Always GETs first, regardless of which flags are set. - const existing = yield* Option.isSome(profileApiUrl) - ? rawGetProvider - : api.v1.getASsoProvider({ ref, provider_id: providerId }).pipe( - Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.catch((cause) => handleGetError(ref, providerId, cause)), - ); - - const body: Record = {}; + const body: SsoUpdateBody = {}; if (Option.isSome(metadataFile)) { const xml = yield* readMetadata(metadataFile.value); - body["metadata_xml"] = xml; + body.metadata_xml = xml; } else if (Option.isSome(metadataUrl)) { if (!skipUrlValidation) { yield* validateMetadataUrl(metadataUrl.value).pipe( @@ -411,98 +360,59 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag ), ); } - body["metadata_url"] = metadataUrl.value; + body.metadata_url = metadataUrl.value; } if (Option.isSome(attributeMappingFile)) { const mapping = yield* readAttributeMapping(attributeMappingFile.value); - body["attribute_mapping"] = mapping; + body.attribute_mapping = mapping; } if (domains.length > 0) { - body["domains"] = [...domains]; + body.domains = [...domains]; } else { // `domains` is always recomputed and sent, even when no domain flag // was passed; an empty merged set serializes as `"domains":[]`, never omitted. - body["domains"] = mergeDomains(existing.domains, addDomains, removeDomains); + body.domains = mergeDomains(existing.domains, addDomains, removeDomains); } if (Option.isSome(nameIdFormat)) { - body["name_id_format"] = nameIdFormat.value; + const format = toNameIdFormat(nameIdFormat.value); + if (format !== undefined) body.name_id_format = format; } - const tokenOpt = - reconciledTokenCached !== undefined - ? yield* Effect.filterOrFail( - reconciledTokenCached, - Option.isSome, - () => new SsoAccessTokenError({ message: missingAccessTokenMessage() }), - ) - : yield* resolveAccessToken; - - // See `add.handler.ts` for the rationale behind `bearerToken(Redacted)`. - const request = requestWithAuth( - HttpClientRequest.put( - `${apiUrl}/v1/projects/${ref}/config/auth/sso/providers/${providerId}`, - ), - tokenOpt, - cliSettings.userAgent, - ).pipe( - // See `add.handler.ts` — key order matters for cli-e2e parity. - HttpClientRequest.bodyText(encodeGoStructJsonBody(body), "application/json"), - ); - - const response = yield* httpClient.execute(request).pipe( + // `executeRaw` keeps the response body intact so structured output emits it as the API + // returned it; the typed schema decodes a copy for the table view. + const { rawBody, provider } = yield* Effect.gen(function* () { + const response = yield* api + .executeRaw( + operationDefinitions.v1UpdateASsoProvider, + { ref, provider_id: providerId, ...body }, + { ...requestOptions, retry: { requestTimeoutMs: Number.POSITIVE_INFINITY } }, + ) + .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); + const rawBody: unknown = yield* response.json; + return { rawBody, provider: yield* decodeProvider(rawBody) }; + }).pipe( + Effect.catch((cause) => handlePutError(gate, cause)), Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.mapError( - (cause) => - new SsoUpdateNetworkError({ - message: `failed to update sso provider: ${String(cause)}`, - }), - ), ); - - if (response.status !== 200) { - const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - // Cap + sanitize to match `mapHttpError`'s defenses; see add handler for the rationale. - const bodyText = sanitizeErrorBody(rawBody); - const upgradeSuggested = yield* suggestUpgrade({ - projectRef: ref, - featureKey: "auth.saml_2", - statusCode: response.status, - response, - apiUrl, - ...(yield* Effect.map(reconciledTokenForAux, (token) => - token !== undefined ? { accessToken: token } : {}, - )), - }); - yield* fetching?.fail() ?? Effect.void; - // Reuses the GET error message even for PUT. - return yield* new SsoUpdateUnexpectedStatusError({ - status: response.status, - body: bodyText, - message: `unexpected error fetching identity provider: ${bodyText}`, - upgradeSuggested, - }); - } - - const parsedJson = yield* response.json.pipe(Effect.orElseSucceed((): unknown => ({}))); yield* fetching?.clear ?? Effect.void; const goFmt = Option.getOrUndefined(goOutputFlag); if (goFmt === "json") { - yield* output.raw(encodeGoJson(parsedJson)); + yield* output.raw(encodeGoJson(rawBody)); return; } if (goFmt === "yaml") { - yield* output.raw(encodeGoYaml(parsedJson, GO_SSO_PROVIDER_RESPONSE)); + yield* output.raw(encodeGoYaml(rawBody, GO_SSO_PROVIDER_RESPONSE)); return; } if (goFmt === "toml") { // Same TOML-encode-failure pattern as list/show. const toml = yield* Effect.try({ - try: () => encodeGoToml(parsedJson, GO_SSO_PROVIDER_RESPONSE), + try: () => encodeGoToml(rawBody, GO_SSO_PROVIDER_RESPONSE), catch: (cause) => new SsoTomlEncodeError({ message: `failed to output toml: ${cause instanceof Error ? cause.message : String(cause)}`, @@ -518,12 +428,12 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag if (output.format === "json" || output.format === "stream-json") { yield* output.success( "", - Predicate.isObjectOrArray(parsedJson) ? { ...parsedJson } : { value: parsedJson }, + Predicate.isObjectOrArray(rawBody) ? { ...rawBody } : { value: rawBody }, ); return; } - yield* output.raw(renderSingleProvider(toSsoProviderView(parsedJson))); + yield* output.raw(renderSingleProvider(provider)); }).pipe( // Linked-project cache fill GETs `/v1/projects/{ref}` through the // reconciled host, never the config layer's. diff --git a/apps/cli/src/commands/sso/update/update.integration.test.ts b/apps/cli/src/commands/sso/update/update.integration.test.ts index 83f008b70a..72aad15622 100644 --- a/apps/cli/src/commands/sso/update/update.integration.test.ts +++ b/apps/cli/src/commands/sso/update/update.integration.test.ts @@ -28,7 +28,6 @@ import { useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; import { ProfileFlag } from "../../../command-internal/global-flags.ts"; -import { IdentityStitch } from "../../../command-internal/identity-stitch.ts"; import { EventUpgradeSuggested } from "../../../shared/telemetry/event-catalog.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; import type { SsoUpdateFlags } from "./update.command.ts"; @@ -55,12 +54,6 @@ interface SetupOpts { goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; getStatus?: number; getBody?: unknown; - /** - * Serves the provider GET as a raw body + content type instead of - * `jsonResponse`, for the reconciled-profile raw GET's decode branches - * (invalid JSON, non-JSON content type). - */ - getRaw?: { status: number; body: string; contentType: string }; putStatus?: number; putBody?: unknown; upgradeGate?: "gated" | "notGated"; @@ -114,17 +107,6 @@ function setup(opts: SetupOpts = {}) { const url = request.url; if (url.includes("/config/auth/sso/providers/")) { if (request.method === "GET") { - if (opts.getRaw !== undefined) { - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(opts.getRaw.body, { - status: opts.getRaw.status, - headers: { "content-type": opts.getRaw.contentType }, - }), - ), - ); - } return Effect.succeed(jsonResponse(request, getStatus, getBody)); } if (request.method === "PUT") @@ -169,17 +151,6 @@ function setup(opts: SetupOpts = {}) { }, }); - // Tracks that the reconciled-profile raw GET stitches identity through the - // same shared guard as the typed client's response transform. - let stitchedResponses = 0; - const stitchLayer = Layer.succeed(IdentityStitch, { - stitch: () => - Effect.sync(() => { - stitchedResponses += 1; - }), - stitchedDistinctId: () => undefined, - }); - const cliSettings = mockCommandSettings({ workdir: tempRoot.current, ...(opts.accessToken !== undefined ? { accessToken: opts.accessToken } : {}), @@ -198,7 +169,6 @@ function setup(opts: SetupOpts = {}) { Stdio.layerTest({ args: Effect.succeed(opts.cliArgs ?? ["sso", "update", VALID_PROVIDER_ID]), }), - stitchLayer, opts.profileFlag === undefined ? Layer.empty : Layer.succeed(ProfileFlag, opts.profileFlag), ConfigProvider.layer( ConfigProvider.fromEnvRecord({ SUPABASE_NO_KEYRING: "1" }, { preserveEmptyStrings: true }), @@ -212,9 +182,6 @@ function setup(opts: SetupOpts = {}) { analytics, telemetry, cache, - get stitchedResponses() { - return stitchedResponses; - }, }; } @@ -1266,6 +1233,24 @@ describe("sso update integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("a GET body that fails the provider schema aborts before the PUT", () => { + const { layer, api } = setup({ + getBody: { id: VALID_PROVIDER_ID, domains: ["not-an-object"] }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(ssoUpdate(defaultFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = Cause.pretty(exit.cause); + expect(dump).toContain("SsoUpdateNetworkError"); + expect(dump).toContain("failed to get sso provider:"); + const classified = classifyCliCauseActionability(exit.cause); + expect(classified.error_fingerprint).toBe("tag:SsoUpdateNetworkError:api_response"); + } + expect(api.requests.some((r) => r.method === "PUT")).toBe(false); + }).pipe(Effect.provide(layer)); + }); + it.effect("PUT 4xx + gated entitlement → unexpected error + cli_upgrade_suggested", () => { // suggestUpgrade fires only on 4xx. const { layer, analytics } = setup({ @@ -1277,7 +1262,11 @@ describe("sso update integration", () => { const exit = yield* Effect.exit(ssoUpdate(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("SsoUpdateUnexpectedStatusError"); + const dump = Cause.pretty(exit.cause); + expect(dump).toContain("SsoUpdateUnexpectedStatusError"); + expect(dump).toContain( + 'unexpected error fetching identity provider: {"error":"forbidden"}', + ); } expect(analytics.captured.some((c) => c.event === EventUpgradeSuggested)).toBe(true); }).pipe(Effect.provide(layer)); @@ -1299,6 +1288,17 @@ describe("sso update integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("Go --output=json keeps response keys outside the OpenAPI schema", () => { + const { layer, out } = setup({ + goOutput: "json", + putBody: { ...RESPONSE_PROVIDER, resource_id: "res-123" }, + }); + return Effect.gen(function* () { + yield* ssoUpdate(defaultFlags); + expect(out.stdoutText).toMatch(/"resource_id":\s*"res-123"/); + }).pipe(Effect.provide(layer)); + }); + it.effect("TS --output-format=json emits success", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { @@ -1388,6 +1388,27 @@ describe("sso update integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("an attribute mapping with an unknown key fails before the PUT", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const path = pathService.join(tempRoot.current, "unknown-key.json"); + yield* fs.writeFileString(path, '{"keys":{"email":{"nam":"email"}}}'); + const flags = { ...defaultFlags, attributeMappingFile: Option.some(path) }; + const { layer, api } = setup({ cliArgs: cliArgsFor(flags) }); + return yield* Effect.gen(function* () { + const exit = yield* Effect.exit(ssoUpdate(flags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = Cause.pretty(exit.cause); + expect(dump).toContain("SsoUpdateAttributeMappingFileError"); + expect(dump).toContain("failed to parse attribute mapping: Expected no excess property"); + } + expect(api.requests.some((r) => r.method === "PUT")).toBe(false); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("--add-domains + --remove-domains combined apply remove then add", () => { const flags = { ...defaultFlags, addDomains: ["new.com"], removeDomains: ["old1.com"] }; const { layer, api } = setup({ cliArgs: cliArgsFor(flags) }); @@ -1425,11 +1446,10 @@ describe("sso update integration", () => { Effect.gen(function* () { const first = yield* writeProfileYaml("first.yml", "http://first.example"); const second = yield* writeProfileYaml("second.yml", "http://second.example"); - const testSetup = setup({ + const { layer, api, cache } = setup({ cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--profile", first, "--profile", second], profileFlag: first, }); - const { layer, api, cache } = testSetup; return yield* Effect.gen(function* () { yield* ssoUpdate(defaultFlags); const providerUrl = `http://second.example/v1/projects/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`; @@ -1440,101 +1460,13 @@ describe("sso update integration", () => { const { domains } = yield* decodePutDomains(put?.body); expect([...domains].sort()).toEqual(["old1.com", "old2.com"]); expect(api.requests.some((r) => r.url.startsWith("http://first.example/"))).toBe(false); - expect(testSetup.stitchedResponses).toBeGreaterThan(0); + expect(get?.headers["authorization"]).toMatch(/^Bearer sbp_/); + expect(put?.headers["authorization"]).toMatch(/^Bearer sbp_/); expect(cache.cachedApiUrl).toBe("http://second.example"); }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); }).pipe(Effect.provide(BunServices.layer)), ); - it.effect( - "profile emulation: the reconciled-host GET maps a 404 exactly like the typed client", - () => - Effect.gen(function* () { - const first = yield* writeProfileYaml("first-404.yml", "http://first.example"); - const second = yield* writeProfileYaml("second-404.yml", "http://second.example"); - const { layer, api } = setup({ - getStatus: 404, - getBody: {}, - cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--profile", first, "--profile", second], - profileFlag: first, - }); - return yield* Effect.gen(function* () { - const exit = yield* Effect.exit(ssoUpdate(defaultFlags)); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const dump = Cause.pretty(exit.cause); - expect(dump).toContain("SsoUpdateNotFoundError"); - expect(dump).toContain( - `An identity provider with ID "${VALID_PROVIDER_ID}" could not be found.`, - ); - } - expect(api.requests.some((r) => r.method === "PUT")).toBe(false); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect( - "profile emulation: the reconciled-host GET maps a non-404 status exactly like the typed client", - () => - Effect.gen(function* () { - const first = yield* writeProfileYaml("first-500.yml", "http://first.example"); - const second = yield* writeProfileYaml("second-500.yml", "http://second.example"); - const { layer, api } = setup({ - getStatus: 500, - getBody: { error: "boom" }, - cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--profile", first, "--profile", second], - profileFlag: first, - }); - return yield* Effect.gen(function* () { - const exit = yield* Effect.exit(ssoUpdate(defaultFlags)); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const dump = Cause.pretty(exit.cause); - expect(dump).toContain("SsoUpdateUnexpectedStatusError"); - expect(dump).toContain("unexpected error fetching identity provider:"); - } - expect(api.requests.some((r) => r.method === "PUT")).toBe(false); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect( - "profile emulation: the reconciled GET narrows odd JSON shapes when merging domains", - () => - Effect.gen(function* () { - const first = yield* writeProfileYaml("first-merge.yml", "http://first.example"); - const second = yield* writeProfileYaml("second-merge.yml", "http://second.example"); - const { layer, api, cache } = setup({ - getBody: { - id: VALID_PROVIDER_ID, - domains: [{ domain: "old1.com" }, "not-an-object", { domain: 42 }], - }, - cliArgs: [ - "sso", - "update", - VALID_PROVIDER_ID, - "--add-domains", - "new.com", - "--profile", - first, - "--profile", - second, - ], - profileFlag: first, - }); - return yield* Effect.gen(function* () { - yield* ssoUpdate({ ...defaultFlags, addDomains: ["new.com"] }); - const put = api.requests.find((r) => r.method === "PUT"); - expect(put?.url).toBe( - `http://second.example/v1/projects/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`, - ); - const { domains } = yield* decodePutDomains(put?.body); - expect([...domains].sort()).toEqual(["new.com", "old1.com"]); - expect(cache.cachedAccessToken).toBeDefined(); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); - }).pipe(Effect.provide(BunServices.layer)), - ); - it.effect("profile emulation: a reconciled profile with no resolvable token aborts like Go", () => Effect.gen(function* () { const first = yield* writeProfileYaml("first-notoken.yml", "http://first.example"); @@ -1603,33 +1535,6 @@ describe("sso update integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("profile emulation: the reconciled GET tolerates a body without a domains array", () => - Effect.gen(function* () { - const first = yield* writeProfileYaml("first-nodom.yml", "http://first.example"); - const second = yield* writeProfileYaml("second-nodom.yml", "http://second.example"); - const { layer, api } = setup({ - getBody: { id: VALID_PROVIDER_ID }, - cliArgs: [ - "sso", - "update", - VALID_PROVIDER_ID, - "--add-domains", - "new.com", - "--profile", - first, - "--profile", - second, - ], - profileFlag: first, - }); - return yield* Effect.gen(function* () { - yield* ssoUpdate({ ...defaultFlags, addDomains: ["new.com"] }); - const put = api.requests.find((r) => r.method === "PUT"); - expect(put?.body).toHaveProperty("domains", ["new.com"]); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); - }).pipe(Effect.provide(BunServices.layer)), - ); - it.effect( "profile emulation: --profile consuming a trailing flag token fails LoadProfile, never GETs", () => { @@ -1649,58 +1554,6 @@ describe("sso update integration", () => { }, ); - it.effect( - "profile emulation: an undecodable 200 body from the reconciled GET aborts before the PUT", - () => - Effect.gen(function* () { - const first = yield* writeProfileYaml("first-badjson.yml", "http://first.example"); - const second = yield* writeProfileYaml("second-badjson.yml", "http://second.example"); - const { layer, api } = setup({ - getRaw: { status: 200, body: "{not json", contentType: "application/json" }, - cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--profile", first, "--profile", second], - profileFlag: first, - }); - return yield* Effect.gen(function* () { - const exit = yield* Effect.exit(ssoUpdate(defaultFlags)); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const dump = Cause.pretty(exit.cause); - expect(dump).toContain("SsoUpdateNetworkError"); - expect(dump).toContain("failed to get sso provider:"); - const classified = classifyCliCauseActionability(exit.cause); - expect(classified.error_kind).toBe("external_service"); - expect(classified.error_category).toBe("api_status"); - expect(classified.error_fingerprint).toBe("tag:SsoUpdateNetworkError:api_response"); - } - expect(api.requests.some((r) => r.method === "PUT")).toBe(false); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect( - "profile emulation: a 200 without a JSON content type maps to the unexpected-status branch, like Go's nil JSON200", - () => - Effect.gen(function* () { - const first = yield* writeProfileYaml("first-nonjson.yml", "http://first.example"); - const second = yield* writeProfileYaml("second-nonjson.yml", "http://second.example"); - const { layer, api } = setup({ - getRaw: { status: 200, body: "plain text body", contentType: "text/plain" }, - cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--profile", first, "--profile", second], - profileFlag: first, - }); - return yield* Effect.gen(function* () { - const exit = yield* Effect.exit(ssoUpdate(defaultFlags)); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const dump = Cause.pretty(exit.cause); - expect(dump).toContain("SsoUpdateUnexpectedStatusError"); - expect(dump).toContain("unexpected error fetching identity provider: plain text body"); - } - expect(api.requests.some((r) => r.method === "PUT")).toBe(false); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); - }).pipe(Effect.provide(BunServices.layer)), - ); - it.effect( "profile emulation: a gated 4xx on the reconciled GET sends the fallback gate requests to the reconciled host", () => diff --git a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts index c8d600b187..5ce6fcc4a4 100644 --- a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts +++ b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts @@ -1,6 +1,7 @@ import type { V1ActivateVanitySubdomainConfigOutput, V1CheckVanitySubdomainAvailabilityOutput, + V1GetProjectOutput, V1GetVanitySubdomainConfigOutput, } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; @@ -25,6 +26,7 @@ import { vanitySubdomainsGet } from "./get/get.handler.ts"; type VanityConfig = typeof V1GetVanitySubdomainConfigOutput.Type; type AvailabilityResponse = typeof V1CheckVanitySubdomainAvailabilityOutput.Type; type ActivateResponse = typeof V1ActivateVanitySubdomainConfigOutput.Type; +type ProjectResponse = typeof V1GetProjectOutput.Type; const tempRoot = useTempWorkdir("supabase-vanity-int-"); @@ -46,6 +48,24 @@ const SAMPLE_ACTIVATE: ActivateResponse = { custom_domain: "example.com", }; +// Schema-valid project body: the upgrade-suggest fallback decodes it with the typed client. +const SAMPLE_PROJECT: ProjectResponse = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "supabase", + name: "Test Project", + region: "us-east-1", + created_at: "2026-01-01T00:00:00Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.example.com", + version: "15", + postgres_engine: "15", + release_channel: "ga", + }, +}; + type GoOutput = "env" | "pretty" | "json" | "toml" | "yaml"; function runtimeWith(opts: { @@ -77,7 +97,7 @@ function gatedApi(matchWrite: (url: string) => boolean) { return jsonResponse(request, 402, {}); } if (request.method === "GET" && request.url.endsWith(`/v1/projects/${VALID_REF}`)) { - return jsonResponse(request, 200, { organization_slug: "supabase" }); + return jsonResponse(request, 200, SAMPLE_PROJECT); } if (request.method === "GET" && request.url.includes("/entitlements")) { return jsonResponse(request, 200, { diff --git a/apps/cli/src/shared/compute/compute-api.ts b/apps/cli/src/shared/compute/compute-api.ts index 29c04bcb59..64ce7b5f72 100644 --- a/apps/cli/src/shared/compute/compute-api.ts +++ b/apps/cli/src/shared/compute/compute-api.ts @@ -48,6 +48,10 @@ import { * so the caller can read it as "not deployed". */ +type ComputeResourceData = typeof V2GetAComputeInstanceOutput.Type extends { data: infer D } + ? D + : never; + /** The compute shape the API returns, flattened out of its JSON:API envelope. */ export interface ComputeRecord { readonly name: string; @@ -58,7 +62,7 @@ export interface ComputeRecord { readonly instances: number; readonly backend?: string; }; - readonly buildState: "building" | "active" | "failed"; + readonly buildState: ComputeResourceData["attributes"]["build_state"]; readonly stateReason?: string; readonly imageVersion?: string; readonly deleting?: boolean; @@ -88,10 +92,6 @@ export interface ComputeDeploySpec { readonly instances: number; } -type ComputeResourceData = typeof V2GetAComputeInstanceOutput.Type extends { data: infer D } - ? D - : never; - function toComputeRecord(data: ComputeResourceData): ComputeRecord { return { name: data.id, diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index 5458c5737a..f6e3edd116 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -1778,6 +1778,9 @@ function rateLimitDelayText(milliseconds: number) { return `${Math.round(milliseconds / 1_000)}s`; } +// The command's own 429 loop is the only retry layer. +const NO_CLIENT_RETRY = { retry: { maxRetries: 0 } } as const; + const rateLimitedRequest = Effect.fnUntraced(function* ( action: string, request: () => Effect.Effect< @@ -1827,15 +1830,19 @@ const uploadFunctionSource = Effect.fn("functions.deploy.uploadFunctionSource")( ); const response = yield* rateLimitedRequest(`deploying function ${config.slug}`, () => api - .executeRaw(operationDefinitions.v1DeployAFunction, { - ref: projectRef, - slug: config.slug, - ...(bundleOnly ? { bundleOnly: true } : {}), - body: { - metadata, - file: files, + .executeRaw( + operationDefinitions.v1DeployAFunction, + { + ref: projectRef, + slug: config.slug, + ...(bundleOnly ? { bundleOnly: true } : {}), + body: { + metadata, + file: files, + }, }, - }) + NO_CLIENT_RETRY, + ) .pipe( // Read the body as text (never failing) so the status check below wins: // a non-201 with a non-JSON body, or a 201 with malformed JSON, must @@ -1871,12 +1878,35 @@ const uploadFunctionSource = Effect.fn("functions.deploy.uploadFunctionSource")( ); }); -function toBulkUpdateItem(remote: RemoteFunction | DeployFunctionResponse): BulkUpdateFunction { +function bulkUpdateStatus( + slug: string, + status: string, +): Effect.Effect { + switch (status) { + case "ACTIVE": + case "REMOVED": + case "THROTTLED": + return Effect.succeed(status); + default: + return Effect.fail( + new FunctionsApiStatusError({ + status: 200, + message: `failed to read function ${slug}: unexpected status ${status}`, + decode: true, + }), + ); + } +} + +const toBulkUpdateItem = Effect.fnUntraced(function* ( + remote: RemoteFunction | DeployFunctionResponse, +) { + const status = yield* bulkUpdateStatus(remote.slug, remote.status); return { id: remote.id, slug: remote.slug, name: remote.name, - status: remote.status, + status, version: remote.version, ...(remote.created_at === undefined ? {} : { created_at: remote.created_at }), ...(remote.verify_jwt == null ? {} : { verify_jwt: remote.verify_jwt }), @@ -1884,8 +1914,8 @@ function toBulkUpdateItem(remote: RemoteFunction | DeployFunctionResponse): Bulk ...(remote.entrypoint_path == null ? {} : { entrypoint_path: remote.entrypoint_path }), ...(remote.import_map_path == null ? {} : { import_map_path: remote.import_map_path }), ...(remote.ezbr_sha256 == null ? {} : { ezbr_sha256: remote.ezbr_sha256 }), - }; -} + } satisfies BulkUpdateFunction; +}); const bulkUpdateRemoteFunctions = Effect.fn("functions.deploy.bulkUpdateFunctions")(function* ( api: ApiClient, @@ -1896,10 +1926,14 @@ const bulkUpdateRemoteFunctions = Effect.fn("functions.deploy.bulkUpdateFunction for (let attempt = 0; attempt <= 3; attempt += 1) { const result = yield* rateLimitedRequest("bulk updating functions", () => api - .executeRaw(operationDefinitions.v1BulkUpdateFunctions, { - ref: projectRef, - body: functions.map(toBulkUpdateItem), - }) + .executeRaw( + operationDefinitions.v1BulkUpdateFunctions, + { + ref: projectRef, + body: functions, + }, + NO_CLIENT_RETRY, + ) .pipe( // Read the body as text (never failing) so the status check wins even // if the body cannot be read. @@ -2284,7 +2318,7 @@ const deployViaApi = Effect.fn("functions.deploy.viaApi")(function* ( (config) => Effect.gen(function* () { yield* output.raw(`Deploying Function: ${config.slug}\n`, "stderr"); - return toBulkUpdateItem( + return yield* toBulkUpdateItem( yield* uploadFunctionSource( api, projectRef, @@ -2398,7 +2432,7 @@ const deployViaDocker = Effect.fn("functions.deploy.viaDocker")(function* ( "stderr", ); changed.push( - toBulkUpdateItem( + yield* toBulkUpdateItem( yield* upsertBundledFunction(api, projectRef, bundled, current !== undefined), ), ); diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index 72b75b90a0..6c24e10614 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -797,7 +797,7 @@ const downloadBody = Effect.fnUntraced(function* ( ref: projectRef, function_slug: slug, }, - { Accept: "multipart/form-data" }, + { headers: { Accept: "multipart/form-data" } }, ) .pipe(Effect.mapError((error) => mapTransportError("failed to download function", error))); @@ -830,7 +830,7 @@ const downloadEszipBody = Effect.fnUntraced(function* ( ref: projectRef, function_slug: slug, }, - { Accept: "*/*" }, + { headers: { Accept: "*/*" } }, ) .pipe(Effect.mapError((error) => mapTransportError("failed to get function body", error))); diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index c89c9770df..b12ad117e6 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -219,7 +219,7 @@ const TABLE_HEADERS = ["SERVICE IMAGE", "LOCAL", "LINKED"] as const; type ProjectApiKey = { readonly name: string; - readonly type?: "legacy" | "publishable" | "secret" | null; + readonly type?: string | null; readonly api_key?: string | null; readonly secret_jwt_template?: Record | null; }; diff --git a/apps/cli/src/shared/services/services.shared.unit.test.ts b/apps/cli/src/shared/services/services.shared.unit.test.ts index 55faeb0ea9..3c3d7cef40 100644 --- a/apps/cli/src/shared/services/services.shared.unit.test.ts +++ b/apps/cli/src/shared/services/services.shared.unit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, test } from "@effect/vitest"; +import { makeApiClient } from "@supabase/api/effect"; import { Effect, Redacted } from "effect"; import { FetchHttpClient } from "effect/http"; import { afterEach, beforeEach, vi } from "vitest"; @@ -56,9 +57,21 @@ const ACCESS_TOKEN = Redacted.make(`sbp_${"a".repeat(40)}`); const PROJECT_REF = "abcdefghijklmnopqrst"; // `fetchLinkedServiceVersions` reads the ambient HttpClient from context instead -// of self-provisioning one, so each invocation needs a concrete transport. +// of self-provisioning one, so each invocation needs a concrete transport. The API client +// skips retries so the 500 fixtures fail at once instead of backing off in real time. const runLinkedFetch = (input: Parameters[0]) => - fetchLinkedServiceVersions(input).pipe(Effect.provide(FetchHttpClient.layer)); + Effect.gen(function* () { + const api = yield* makeApiClient( + { + baseUrl: input.apiUrl, + accessToken: input.accessToken, + userAgent: input.userAgent, + headers: input.headers, + }, + { retry: { maxRetries: 0 } }, + ); + return yield* fetchLinkedServiceVersions({ ...input, api }); + }).pipe(Effect.provide(FetchHttpClient.layer)); // Tenant probes share the test server's origin, so only Management API paths are recorded. const recordManagementRequests = () => { diff --git a/apps/cli/src/telemetry/linked-project-cache.integration.test.ts b/apps/cli/src/telemetry/linked-project-cache.integration.test.ts index 52413f1b99..61ed4904a3 100644 --- a/apps/cli/src/telemetry/linked-project-cache.integration.test.ts +++ b/apps/cli/src/telemetry/linked-project-cache.integration.test.ts @@ -32,9 +32,19 @@ describe("linkedProjectCacheLayer", () => { new Response( JSON.stringify({ ref: VALID_REF, + id: VALID_REF, name: "proj", organization_id: "org-1", organization_slug: "acme", + region: "us-east-1", + created_at: "2026-01-01T00:00:00Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.example.com", + version: "15", + postgres_engine: "15", + release_channel: "ga", + }, }), { status: 200, diff --git a/apps/cli/src/telemetry/linked-project-cache.layer.ts b/apps/cli/src/telemetry/linked-project-cache.layer.ts index 406ebc6e4f..466d0c0d1c 100644 --- a/apps/cli/src/telemetry/linked-project-cache.layer.ts +++ b/apps/cli/src/telemetry/linked-project-cache.layer.ts @@ -1,6 +1,6 @@ -import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; +import { makeApiClient } from "@supabase/api/effect"; +import { Effect, FileSystem, Layer, Option, Path, type Redacted } from "effect"; import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; import { CommandCredentials } from "../auth/command-credentials.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; @@ -20,23 +20,13 @@ export const linkedProjectCache = block("linked-project-cache", [ }, ]); -function readString(obj: unknown, key: string): string { - if (typeof obj === "object" && obj !== null && key in obj) { - const value = (obj as Record)[key]; - return typeof value === "string" ? value : ""; - } - return ""; -} - /** * Writes `/supabase/.temp/linked-project.json` after a `--project-ref` has been * resolved. No write if the cache already exists (`supabase link` is authoritative), and any * API/filesystem/parse error is swallowed. * - * Bypasses `CommandPlatformApi`'s strict schema decode by calling the API directly with - * `HttpClient`. The generated `V1ProjectWithDatabaseResponse` schema enforces a 20-char - * project-ref length that the cli-e2e replay fixtures (which store `__PROJECT_REF__` - * placeholders) cannot satisfy; the cache only needs four string fields and doesn't validate them. + * Builds its own typed client per fill rather than consuming `CommandPlatformApi`, which + * requires a stored token at construction; this layer skips quietly when there is none. * * Also skips the write when `/supabase/.temp/project-ref` exists, is non-empty, and * names a different ref than `ref` — see the inline comment at the check itself for the failure @@ -96,27 +86,29 @@ export const linkedProjectCacheLayer = Layer.effect( ? cliSettings.accessToken : yield* credentials.getAccessToken); if (Option.isNone(tokenOpt)) return; - const token = Redacted.value(tokenOpt.value); - - const request = HttpClientRequest.get( - `${apiUrl ?? cliSettings.apiUrl}/v1/projects/${ref}`, - ).pipe( - HttpClientRequest.setHeader("Authorization", `Bearer ${token}`), - HttpClientRequest.setHeader("User-Agent", cliSettings.userAgent), - ); - const response = yield* httpClient.execute(request); - // Stitch identity from the response before the status gate, regardless of status. - yield* stitch(response); - yield* Effect.annotateCurrentSpan({ "http.response.status_code": response.status }); - if (response.status !== 200) return; - const body = yield* response.json; + const api = yield* makeApiClient( + { + baseUrl: apiUrl ?? cliSettings.apiUrl, + accessToken: tokenOpt.value, + userAgent: cliSettings.userAgent, + }, + { + transformClient: (client) => + Effect.succeed( + HttpClient.transform(client, (requestEffect) => + requestEffect.pipe(Effect.tap(stitch)), + ), + ), + }, + ).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); + const project = yield* api.v1.getProject({ ref }, { retry: { maxRetries: 0 } }); const linked = { - ref: readString(body, "ref"), - name: readString(body, "name"), - organization_id: readString(body, "organization_id"), - organization_slug: readString(body, "organization_slug"), + ref: project.ref, + name: project.name, + organization_id: project.organization_id, + organization_slug: project.organization_slug, }; yield* fs.makeDirectory(path.dirname(cachePath), { recursive: true }); diff --git a/apps/cli/tests/helpers/command-mocks.ts b/apps/cli/tests/helpers/command-mocks.ts index cd1594e2b8..ed51b6b288 100644 --- a/apps/cli/tests/helpers/command-mocks.ts +++ b/apps/cli/tests/helpers/command-mocks.ts @@ -592,11 +592,15 @@ export function mockCommandPlatformApi( const layer = Layer.effect( CommandPlatformApi, - makeApiClient({ - baseUrl: opts.apiUrl ?? DEFAULT_API_URL, - accessToken: opts.accessToken ?? VALID_TOKEN, - userAgent: opts.userAgent ?? DEFAULT_USER_AGENT, - }), + // Retry backoff is covered in `@supabase/api`; stubbed failures must surface without waiting. + makeApiClient( + { + baseUrl: opts.apiUrl ?? DEFAULT_API_URL, + accessToken: opts.accessToken ?? VALID_TOKEN, + userAgent: opts.userAgent ?? DEFAULT_USER_AGENT, + }, + { retry: { maxRetries: 0 } }, + ), ).pipe(Layer.provide(httpClientLayer)); const factoryLayer = Layer.succeed(CommandPlatformApiFactory, { diff --git a/apps/cli/tests/helpers/compute.ts b/apps/cli/tests/helpers/compute.ts index 91a2293b62..5b402562dd 100644 --- a/apps/cli/tests/helpers/compute.ts +++ b/apps/cli/tests/helpers/compute.ts @@ -179,15 +179,19 @@ function mockComputeHttp(routes: ComputeHttpRoutes) { const apiLayer = Layer.effect( CommandPlatformApi, - makeApiClient({ - baseUrl: "https://api.supabase.com", - accessToken: "test-token", - userAgent: "supabase", - headers: { - "X-Supabase-Command": "compute", - "X-Supabase-Command-Run-ID": "run-123", + // The commands' own retry schedules are under test; the client's backoff must not hide them. + makeApiClient( + { + baseUrl: "https://api.supabase.com", + accessToken: "test-token", + userAgent: "supabase", + headers: { + "X-Supabase-Command": "compute", + "X-Supabase-Command-Run-ID": "run-123", + }, }, - }), + { retry: { maxRetries: 0 } }, + ), ).pipe(Layer.provide(httpClientLayer)); return { diff --git a/apps/cli/tests/helpers/permission-real-layers.ts b/apps/cli/tests/helpers/permission-real-layers.ts index c7ada964a6..279b8edd09 100644 --- a/apps/cli/tests/helpers/permission-real-layers.ts +++ b/apps/cli/tests/helpers/permission-real-layers.ts @@ -176,6 +176,24 @@ export const provideEnv = (env: Record) => ConfigProvider.fromEnvRecord(env, { preserveEmptyStrings: true }), ); +/** A project as `GET /v1/projects/{ref}` returns it, with every field the typed client's decoder requires. */ +export const projectResponse = (ref: string) => ({ + id: ref, + ref, + name: "alpha", + organization_id: "org-123", + organization_slug: "acme", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}); + /** * An `HttpClient` that records each request and answers the project lookup for `ref`, which is * what the real linked-project cache fetches. @@ -191,12 +209,7 @@ export function recordingProjectHttpClient(ref: string) { HttpClientResponse.fromWeb( request, found - ? Response.json({ - ref, - name: "alpha", - organization_id: "org-123", - organization_slug: "acme", - }) + ? Response.json(projectResponse(ref)) : Response.json({ message: "unexpected request" }, { status: 404 }), ), ); diff --git a/apps/cli/tests/lint/no-raw-management-api-url.unit.test.ts b/apps/cli/tests/lint/no-raw-management-api-url.unit.test.ts new file mode 100644 index 0000000000..62d0f0a0f0 --- /dev/null +++ b/apps/cli/tests/lint/no-raw-management-api-url.unit.test.ts @@ -0,0 +1,99 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url)); +const oxlintBin = join(repoRoot, "node_modules/.bin/oxlint"); +const pluginPath = join(repoRoot, "tools/oxlint/supabase-plugin.js"); + +const RULE_NAME = "no-raw-management-api-url"; +const RULE = `supabase/${RULE_NAME}`; + +const flagged = { + "template-base.ts": "export const url = `${apiUrl}/v1/projects`;", + "template-nested-path.ts": "export const url = `${apiUrl}/v2/projects/${ref}/config`;", + "template-platform.ts": "export const url = `${apiUrl}/platform/cli/login/${id}`;", + "template-absolute.ts": "export const url = `https://api.supabase.com/v1/projects/${ref}`;", + "concatenation.ts": 'export const url = apiUrl + "/v1/projects";', + "url-constructor.ts": 'export const url = new URL("/v1/projects", apiUrl);', + "http-client-request.ts": + 'export const req = HttpClientRequest.get("https://api.supabase.com/v1/projects");', + "fetch-call.ts": 'export const res = fetch("/v2/projects");', + "client-method-literal.ts": 'export const res = client.get("/v1/profile");', + "http-client-request-relative.ts": 'export const req = HttpClientRequest.get("/v1/projects");', + "constant-path-concatenation.ts": [ + 'const PROFILE_PATH = "/v1/profile";', + "export const url = apiUrl + PROFILE_PATH;", + ].join("\n"), + "constant-path-template.ts": [ + 'const PROFILE_PATH = "/v1/profile";', + "export const url = `${apiUrl}${PROFILE_PATH}`;", + ].join("\n"), +}; + +const allowed = { + "service-rest.ts": "export const url = `${baseUrl}/rest/v1/`;", + "service-auth.ts": "export const url = `${baseUrl}/auth/v1/health`;", + "service-storage.ts": 'export const req = HttpClientRequest.get("/storage/v1/bucket");', + "service-functions.ts": "export const url = `http://127.0.0.1:${port}/functions/v1/${name}`;", + "studio-platform.ts": "export const url = `${studioUrl}/api/platform/profile`;", + "otlp-traces-path.ts": 'export const OTLP_TRACES_PATH = "/v1/traces";', + "unrelated-literal.ts": 'export const res = client.get("/health");', + "version-lookalike.ts": 'export const label = "/v10/projects";', + "documented-exception.ts": [ + `// oxlint-disable-next-line ${RULE} -- not in the OpenAPI spec`, + "export const url = `${apiUrl}/platform/cli/login/${id}`;", + ].join("\n"), +}; + +describe("no-raw-management-api-url", () => { + let dir: string; + let flaggedFiles: Set; + let lintedFiles: Set; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "supabase-lint-rule-")); + mkdirSync(join(dir, "src")); + writeFileSync( + join(dir, ".oxlintrc.json"), + JSON.stringify({ + jsPlugins: [pluginPath], + categories: { correctness: "off" }, + rules: { [RULE]: "error" }, + }), + ); + for (const [name, source] of Object.entries({ ...flagged, ...allowed })) { + writeFileSync(join(dir, "src", name), `${source}\n`); + } + + const result = spawnSync( + oxlintBin, + ["--config", join(dir, ".oxlintrc.json"), "--format", "json", join(dir, "src")], + { encoding: "utf8" }, + ); + const report = JSON.parse(result.stdout) as { + diagnostics: { filename: string; code: string }[]; + number_of_files: number; + }; + flaggedFiles = new Set( + report.diagnostics.filter((d) => d.code.includes(RULE_NAME)).map((d) => basename(d.filename)), + ); + lintedFiles = new Set(Object.keys({ ...flagged, ...allowed })); + expect(report.number_of_files).toBe(lintedFiles.size); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test.each(Object.keys(flagged))("flags %s", (name) => { + expect(flaggedFiles.has(name)).toBe(true); + }); + + test.each(Object.keys(allowed))("allows %s", (name) => { + expect(flaggedFiles.has(name)).toBe(false); + }); +}); diff --git a/packages/api/scripts/download-openapi.ts b/packages/api/scripts/download-openapi.ts index fd368bc06d..48edc093fd 100644 --- a/packages/api/scripts/download-openapi.ts +++ b/packages/api/scripts/download-openapi.ts @@ -26,7 +26,7 @@ type OpenApiSource = { readonly baseUrl: string; }; -type JsonPatchOperation = +type JsonPatchOperation = ( | { readonly op: "add" | "test" | "replace"; readonly path: string; @@ -35,7 +35,10 @@ type JsonPatchOperation = | { readonly op: "remove"; readonly path: string; - }; + } +) & { + readonly $comment?: unknown; +}; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; @@ -55,6 +58,10 @@ function jsonPointerSegments(pointer: string): ReadonlyArray { return pointer.slice(1).split("/").map(unescapeJsonPointerSegment); } +function pointerFromSegments(segments: ReadonlyArray): string { + return segments.map((segment) => `/${segment.replace(/~/g, "~0").replace(/\//g, "~1")}`).join(""); +} + function getJsonPointerValue(document: unknown, pointer: string): unknown { let current = document; for (const segment of jsonPointerSegments(pointer)) { @@ -79,10 +86,7 @@ function replaceJsonPointerValue(document: unknown, pointer: string, value: unkn throw new Error("Replacing the document root is not supported."); } - let parent = document; - for (const segment of segments.slice(0, -1)) { - parent = getJsonPointerValue(parent, `/${segment.replace(/~/g, "~0").replace(/\//g, "~1")}`); - } + const parent = getJsonPointerValue(document, pointerFromSegments(segments.slice(0, -1))); const key = segments[segments.length - 1]!; if (Array.isArray(parent)) { @@ -106,10 +110,7 @@ function addJsonPointerValue(document: unknown, pointer: string, value: unknown) throw new Error("Adding the document root is not supported."); } - let parent = document; - for (const segment of segments.slice(0, -1)) { - parent = getJsonPointerValue(parent, `/${segment.replace(/~/g, "~0").replace(/\//g, "~1")}`); - } + const parent = getJsonPointerValue(document, pointerFromSegments(segments.slice(0, -1))); const key = segments[segments.length - 1]!; if (Array.isArray(parent)) { @@ -127,44 +128,27 @@ function addJsonPointerValue(document: unknown, pointer: string, value: unknown) parent[key] = value; } -function removeJsonPointerValue(document: unknown, pointer: string): boolean { +function removeJsonPointerValue(document: unknown, pointer: string): void { const segments = jsonPointerSegments(pointer); if (segments.length === 0) { - return false; - } - - let parent: unknown = document; - for (const segment of segments.slice(0, -1)) { - if (Array.isArray(parent)) { - const index = Number(segment); - if (!Number.isInteger(index) || index < 0 || index >= parent.length) { - return false; - } - parent = parent[index]; - continue; - } - if (isRecord(parent) && segment in parent) { - parent = parent[segment]; - continue; - } - return false; + throw new Error("Removing the document root is not supported."); } + const parent = getJsonPointerValue(document, pointerFromSegments(segments.slice(0, -1))); const key = segments[segments.length - 1]!; if (Array.isArray(parent)) { const index = Number(key); if (!Number.isInteger(index) || index < 0 || index >= parent.length) { - return false; + throw new Error(`JSON pointer ${JSON.stringify(pointer)} does not exist.`); } parent.splice(index, 1); - return true; + return; } if (!isRecord(parent) || !(key in parent)) { - return false; + throw new Error(`JSON pointer ${JSON.stringify(pointer)} does not exist.`); } delete parent[key]; - return true; } function assertJsonPatchOperation(value: unknown): asserts value is JsonPatchOperation { @@ -194,7 +178,22 @@ function assertJsonPatchOperation(value: unknown): asserts value is JsonPatchOpe } function valuesEqual(left: unknown, right: unknown): boolean { - return JSON.stringify(left) === JSON.stringify(right); + if (Array.isArray(left) || Array.isArray(right)) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((entry, index) => valuesEqual(entry, right[index])) + ); + } + if (isRecord(left) && isRecord(right)) { + const leftKeys = Object.keys(left); + return ( + leftKeys.length === Object.keys(right).length && + leftKeys.every((key) => key in right && valuesEqual(left[key], right[key])) + ); + } + return left === right; } export function applyOpenApiOverrides( @@ -217,24 +216,59 @@ export function applyOpenApiOverrides( continue; } if (override.op === "remove") { - // Silently ignores removing a pointer that doesn't exist (a deviation from - // RFC 6902): staging's /api/v2-json is served by two backend variants that - // disagree about whether the webhook paths exist, so a strict remove would - // fail on most staging runs. removeJsonPointerValue(document, override.path); continue; } + if (valuesEqual(getJsonPointerValue(document, override.path), override.value)) { + throw new Error( + `OpenAPI override replace at ${override.path} is redundant: upstream already has ${JSON.stringify(override.value)}.`, + ); + } replaceJsonPointerValue(document, override.path, override.value); } return document; } -async function loadOpenApiOverrides(): Promise> { - const parsed = JSON.parse(await readFile(OPENAPI_OVERRIDES_PATH, "utf8")); +function hasComment(override: JsonPatchOperation): boolean { + return typeof override.$comment === "string" && override.$comment.trim().length > 0; +} + +/** + * Validates the override list's structure: every `replace` is guarded by a `test` on the same + * path directly before it, and every test+replace pair or standalone entry explains its upstream + * cause in a `$comment`. + */ +export function parseOpenApiOverrides(parsed: unknown): ReadonlyArray { if (!Array.isArray(parsed)) { throw new Error("OpenAPI overrides file must contain a JSON Patch array."); } - return parsed; + const entries: Array = []; + for (const override of parsed) { + assertJsonPatchOperation(override); + entries.push(override); + } + + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]!; + const next = entries[index + 1]; + const isGuardedPair = entry.op === "test" && next?.op === "replace" && next.path === entry.path; + if (entry.op === "replace") { + throw new Error( + `OpenAPI override replace at ${entry.path} must be directly preceded by a test on the same path.`, + ); + } + if (!hasComment(entry) && !(isGuardedPair && hasComment(next))) { + throw new Error(`OpenAPI override ${entry.op} at ${entry.path} needs a $comment.`); + } + if (isGuardedPair) { + index++; + } + } + return entries; +} + +export async function loadOpenApiOverrides(): Promise> { + return parseOpenApiOverrides(JSON.parse(await readFile(OPENAPI_OVERRIDES_PATH, "utf8"))); } function assertOpenApiSource(value: unknown): asserts value is OpenApiSource { @@ -398,9 +432,6 @@ export function mergeOpenApiDocuments( }; } -// Must run after overrides are applied: some upstream webhook operations share -// duplicate operationIds that the overrides remove, so validating first would -// abort regeneration. export function assertMergedOpenApiDocument(document: OpenApiDocument): void { const operationClaims = new Map>(); diff --git a/packages/api/scripts/download-openapi.unit.test.ts b/packages/api/scripts/download-openapi.unit.test.ts index a7521eb899..6f600f29bb 100644 --- a/packages/api/scripts/download-openapi.unit.test.ts +++ b/packages/api/scripts/download-openapi.unit.test.ts @@ -4,7 +4,9 @@ import { applyOpenApiOverrides, assertMergedOpenApiDocument, assertOpenApiDocument, + loadOpenApiOverrides, mergeOpenApiDocuments, + parseOpenApiOverrides, resolveOpenApiBaseUrl, resolveOpenApiSpecUrl, resolveOpenApiSpecUrls, @@ -322,58 +324,94 @@ describe("download-openapi", () => { warnSpy.mockRestore(); }); - test("applyOpenApiOverrides tolerantly removes JSON pointers that no longer exist", () => { + test("applyOpenApiOverrides removes existing JSON pointers", () => { const withExistingPath = { paths: { "/v1/foo": { get: {} } } }; applyOpenApiOverrides(withExistingPath, [{ op: "remove", path: "/paths/~1v1~1foo" }]); expect(withExistingPath.paths).toEqual({}); - const withMissingPath = { paths: {} }; - applyOpenApiOverrides(withMissingPath, [{ op: "remove", path: "/paths/~1v1~1missing" }]); - expect(withMissingPath.paths).toEqual({}); - - const withMissingIntermediateSegment = { paths: {} }; - applyOpenApiOverrides(withMissingIntermediateSegment, [ - { op: "remove", path: "/paths/~1nope/get" }, - ]); - expect(withMissingIntermediateSegment.paths).toEqual({}); - const withArray = { paths: {}, components: { schemas: { Foo: { enum: ["a", "b", "c"] } } } }; applyOpenApiOverrides(withArray, [{ op: "remove", path: "/components/schemas/Foo/enum/1" }]); expect(withArray.components.schemas.Foo.enum).toEqual(["a", "c"]); }); + test("fails when a remove override targets a missing pointer", () => { + expect(() => + applyOpenApiOverrides({ paths: {} }, [{ op: "remove", path: "/paths/~1v1~1missing" }]), + ).toThrow('JSON pointer "/paths/~1v1~1missing" does not exist.'); + expect(() => + applyOpenApiOverrides({ paths: {} }, [{ op: "remove", path: "/paths/~1nope/get" }]), + ).toThrow('JSON pointer "/paths/~1nope" does not exist.'); + }); + + test("fails when a replace override already matches upstream", () => { + expect(() => + applyOpenApiOverrides( + { paths: {}, components: { schemas: { Body: { required: ["a", "b"] } } } }, + [ + { op: "test", path: "/components/schemas/Body/required", value: ["a", "b"] }, + { op: "replace", path: "/components/schemas/Body/required", value: ["a", "b"] }, + ], + ), + ).toThrow("is redundant"); + }); + test("rejects a remove override that carries a value", () => { expect(() => applyOpenApiOverrides({ paths: {} }, [{ op: "remove", path: "/paths", value: {} }]), ).toThrow("OpenAPI remove overrides must not include a value."); }); - test("assertMergedOpenApiDocument passes after the webhook-collision remove overrides are applied but fails without them", () => { - const buildDocument = () => ({ - paths: { - "/v2/projects/{ref}/webhooks/endpoints": { - get: { operationId: "allV2ProjectsByRefWebhooks" }, - }, - "/v2/projects/{ref}/webhooks/endpoints/{id}": { - get: { operationId: "allV2ProjectsByRefWebhooks" }, - }, - "/v2/projects/{ref}": { - get: { operationId: "v2-get-a-project" }, - }, - }, + describe("override file structure", () => { + const test_ = (path: string, value: unknown = []) => ({ op: "test", path, value }); + const replace = (path: string, value: unknown = ["x"]) => ({ op: "replace", path, value }); + + test("accepts the committed overrides file", async () => { + const overrides = await loadOpenApiOverrides(); + + expect(overrides.length).toBeGreaterThan(0); }); - const overrides = [ - { op: "remove", path: "/paths/~1v2~1projects~1{ref}~1webhooks~1endpoints" }, - { op: "remove", path: "/paths/~1v2~1projects~1{ref}~1webhooks~1endpoints~1{id}" }, - ]; + test("rejects a replace that is not directly preceded by a test on the same path", () => { + expect(() => + parseOpenApiOverrides([{ ...replace("/a"), $comment: "Upstream is wrong." }]), + ).toThrow("must be directly preceded by a test on the same path"); + expect(() => + parseOpenApiOverrides([ + { ...test_("/a"), $comment: "Upstream is wrong." }, + { op: "remove", path: "/c", $comment: "Upstream is wrong." }, + replace("/a"), + ]), + ).toThrow("must be directly preceded by a test on the same path"); + expect(() => + parseOpenApiOverrides([{ ...test_("/a"), $comment: "Upstream is wrong." }, replace("/b")]), + ).toThrow("must be directly preceded by a test on the same path"); + }); - expect(() => assertMergedOpenApiDocument(buildDocument())).toThrow( - 'Duplicate OpenAPI operationId "allV2ProjectsByRefWebhooks"', - ); + test("requires a $comment on every pair and standalone entry", () => { + expect(() => parseOpenApiOverrides([test_("/a"), replace("/a")])).toThrow( + "test at /a needs a $comment", + ); + expect(() => parseOpenApiOverrides([{ op: "remove", path: "/a" }])).toThrow( + "remove at /a needs a $comment", + ); + expect(() => + parseOpenApiOverrides([{ op: "add", path: "/a", value: 1, $comment: " " }]), + ).toThrow("add at /a needs a $comment"); + }); - const patchedDocument = applyOpenApiOverrides(buildDocument(), overrides); - expect(Object.keys(patchedDocument.paths)).toEqual(["/v2/projects/{ref}"]); - expect(() => assertMergedOpenApiDocument(patchedDocument)).not.toThrow(); + test("accepts a commented test and replace pair on either entry", () => { + expect( + parseOpenApiOverrides([ + test_("/a"), + { ...replace("/a"), $comment: "Upstream is wrong." }, + { ...test_("/b"), $comment: "Upstream is wrong." }, + replace("/b"), + ]), + ).toHaveLength(4); + }); + + test("rejects a file that is not a JSON Patch array", () => { + expect(() => parseOpenApiOverrides({})).toThrow("must contain a JSON Patch array"); + }); }); }); diff --git a/packages/api/scripts/generate.ts b/packages/api/scripts/generate.ts index 252dbf6305..4e5f22f03f 100644 --- a/packages/api/scripts/generate.ts +++ b/packages/api/scripts/generate.ts @@ -246,13 +246,14 @@ export function sanitizeOpenApiSchema( continue; } - if ( - !inPropertiesMap && - key === "propertyNames" && - isRecord(rawValue) && - Object.keys(rawValue).length === 1 && - rawValue.type === "string" - ) { + // The OpenAPI 3.0 converter rejects `propertyNames`; key constraints are left to the server. + if (!inPropertiesMap && key === "propertyNames" && isRecord(rawValue)) { + continue; + } + + // The OpenAPI 3.0 converter rejects `const`; a one-value `enum` is equivalent. + if (!inPropertiesMap && key === "const") { + Object.assign(sanitized, { enum: [rawValue] }); continue; } @@ -310,15 +311,135 @@ export function sanitizeOpenApiSchema( return sanitized; } -function convertSchemaToDraft2020(schema: OpenApiSchema) { +function convertSchemaToDraft2020( + schema: OpenApiSchema, + options?: { readonly openEnums: boolean }, +) { const sanitized = sanitizeOpenApiSchema(schema); + const prepared = options?.openEnums === true ? openStringEnums(sanitized) : sanitized; return ( - sanitized.$schema === JsonSchema.META_SCHEMA_URI_DRAFT_2020_12 - ? JsonSchema.fromSchemaDraft2020_12(sanitized) - : JsonSchema.fromSchemaOpenApi3_0(sanitized) + prepared.$schema === JsonSchema.META_SCHEMA_URI_DRAFT_2020_12 + ? JsonSchema.fromSchemaDraft2020_12(prepared) + : JsonSchema.fromSchemaOpenApi3_0(prepared) ).schema; } +const OPEN_STRING_FORMAT = "x-open-string"; +const COMPONENT_REF_PREFIX = "#/components/schemas/"; + +function isOpenableStringEnum(schema: OpenApiSchema): boolean { + if (!Array.isArray(schema.enum) || (schema.type !== undefined && schema.type !== "string")) { + return false; + } + const values = schema.enum.filter((value) => value !== null); + return values.length > 1 && values.every((value) => typeof value === "string"); +} + +/** + * Widens every multi-value string enum to `enum | any string`. Single-value enums stay closed + * because they tag union variants, and widening them would make variant matching ambiguous. + */ +export function openStringEnums(schema: OpenApiSchema): OpenApiSchema { + const visitSchema = (value: OpenApiSchema): OpenApiSchema => { + const visited: OpenApiSchema = {}; + for (const [key, entry] of Object.entries(value)) { + visited[key] = visit(entry); + } + return isOpenableStringEnum(visited) + ? { anyOf: [visited, { type: "string", format: OPEN_STRING_FORMAT }] } + : visited; + }; + const visit = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(visit); + } + if (!isRecord(value)) { + return value; + } + return visitSchema(value); + }; + return visitSchema(schema); +} + +function collectComponentRefs(value: unknown, into: Set): void { + if (Array.isArray(value)) { + for (const entry of value) { + collectComponentRefs(entry, into); + } + } else if (isRecord(value)) { + for (const [key, entry] of Object.entries(value)) { + if (key === "$ref" && typeof entry === "string" && entry.startsWith(COMPONENT_REF_PREFIX)) { + into.add(entry.slice(COMPONENT_REF_PREFIX.length)); + } else { + collectComponentRefs(entry, into); + } + } + } +} + +function componentRefClosure( + components: Record, + roots: ReadonlyArray, +): ReadonlySet { + const pending = new Set(); + for (const root of roots) { + collectComponentRefs(root, pending); + } + const reachable = new Set(); + for (const name of pending) { + if (reachable.has(name)) { + continue; + } + reachable.add(name); + const component = components[name]; + if (component !== undefined) { + collectComponentRefs(component, pending); + } + } + return reachable; +} + +function containsOpenableStringEnum(value: unknown): boolean { + if (Array.isArray(value)) { + return value.some(containsOpenableStringEnum); + } + return ( + isRecord(value) && + (isOpenableStringEnum(value) || Object.values(value).some(containsOpenableStringEnum)) + ); +} + +/** + * Components reachable from response schemas whose string enums are widened. A component that + * both requests and responses reach would need two variants, which the generator does not model. + */ +function outputEnumComponents( + components: Record, + operations: ReadonlyArray, +): ReadonlySet { + const outputRoots = operations.flatMap((operation) => + operation.response.kind !== "void" && operation.outputSchema ? [operation.outputSchema] : [], + ); + const outputReachable = componentRefClosure(components, outputRoots); + const inputReachable = componentRefClosure( + components, + operations.map((operation) => operation.inputSchema), + ); + for (const name of outputReachable) { + const component = components[name]; + if ( + inputReachable.has(name) && + component !== undefined && + containsOpenableStringEnum(sanitizeOpenApiSchema(component)) + ) { + throw new Error( + `Schema "${name}" has string enums and is reachable from both request and response schemas; response enums are widened, so it cannot be shared.`, + ); + } + } + return outputReachable; +} + function containsBinarySchema(schema: OpenApiSchema): boolean { if (schema.$ref !== undefined) { return false; @@ -351,6 +472,16 @@ function replaceBinarySchemaCode(code: string): string { return code.replace(/Schema\.String\.annotate\(\{\s*"format":\s*"binary"\s*\}\)/g, "BinaryInput"); } +function replaceOpenStringCode(code: string): string { + return code.replace( + new RegExp( + `Schema\\.String\\.annotate\\(\\{\\s*"format":\\s*"${OPEN_STRING_FORMAT}"\\s*\\}\\)`, + "g", + ), + "OpenString", + ); +} + function normalizeJsonSchemaValue(value: unknown): unknown { if (Array.isArray(value)) { return value.map(normalizeJsonSchemaValue); @@ -755,10 +886,15 @@ function renderSchemaSource( operations: ReadonlyArray, ): string { const schemaEntries = operations.flatMap((operation) => { - const entries: Array<{ readonly name: string; readonly schema: OpenApiSchema }> = [ + const entries: Array<{ + readonly name: string; + readonly schema: OpenApiSchema; + readonly isOutput: boolean; + }> = [ { name: operation.inputSchemaName, schema: operation.inputSchema, + isOutput: false, }, ]; @@ -766,22 +902,29 @@ function renderSchemaSource( entries.push({ name: operation.outputSchemaName, schema: operation.outputSchema, + isOutput: true, }); } return entries; }); + const components = document.components?.schemas ?? {}; + const openEnumComponents = outputEnumComponents(components, operations); const definitions = Object.fromEntries( - Object.entries(document.components?.schemas ?? {}).map(([name, schema]) => [ + Object.entries(components).map(([name, schema]) => [ name, - normalizeNullableJsonSchema(convertSchemaToDraft2020(schema)), + normalizeNullableJsonSchema( + convertSchemaToDraft2020(schema, { openEnums: openEnumComponents.has(name) }), + ), ]), ); const nameMap = schemaEntries.map((entry) => entry.name); const schemas = schemaEntries.map((entry) => - normalizeNullableJsonSchema(convertSchemaToDraft2020(entry.schema)), + normalizeNullableJsonSchema( + convertSchemaToDraft2020(entry.schema, { openEnums: entry.isOutput }), + ), ); if (!Arr.isArrayNonEmpty(schemas)) { @@ -818,15 +961,16 @@ function renderSchemaSource( containsBinarySchema(operation.inputSchema), ); + const finalizeCode = (code: string): string => { + const withOpenStrings = replaceOpenStringCode(code); + return hasBinaryInputs ? replaceBinarySchemaCode(withOpenStrings) : withOpenStrings; + }; + const parts: Array = []; if (codeDocument.references.nonRecursives.length > 0) { parts.push("// non-recursive definitions"); for (const reference of codeDocument.references.nonRecursives) { - parts.push( - `export const ${reference.$ref} = ${ - hasBinaryInputs ? replaceBinarySchemaCode(reference.code.runtime) : reference.code.runtime - }`, - ); + parts.push(`export const ${reference.$ref} = ${finalizeCode(reference.code.runtime)}`); } } @@ -835,7 +979,7 @@ function renderSchemaSource( parts.push("// recursive definitions"); for (const [name, code] of recursiveEntries) { parts.push( - `export type ${name} = ${code.Type}\nexport const ${name} = ${hasBinaryInputs ? replaceBinarySchemaCode(code.runtime) : code.runtime}`, + `export type ${name} = ${code.Type}\nexport const ${name} = ${finalizeCode(code.runtime)}`, ); } } @@ -851,9 +995,7 @@ function renderSchemaSource( parts.push("// operation schemas"); for (const [index, code] of codeDocument.codes.entries()) { const name = nameMap[index]!; - parts.push( - `export const ${name} = ${hasBinaryInputs ? replaceBinarySchemaCode(code.runtime) : code.runtime}`, - ); + parts.push(`export const ${name} = ${finalizeCode(code.runtime)}`); } } @@ -863,6 +1005,14 @@ function renderSchemaSource( } } + // `string & {}` keeps the literal members of a widened enum visible to autocomplete. + if (parts.some((part) => /\bOpenString\b/u.test(part))) { + parts.unshift( + "// open string helper", + 'export const OpenString = Schema.declare((value: unknown): value is string & {} => typeof value === "string")', + ); + } + return parts.join("\n") + "\n"; } @@ -960,19 +1110,21 @@ export function renderEffectClient(operations: ReadonlyArray = {}" : `input: typeof operationDefinitions.${operation.operationName}.inputSchema.Type`; const inputExpression = isEmptyInput ? "{}" : "input"; return ` ${methodName}: ( - ${signature} + ${inputParameter}, + options?: SupabaseApiRequestOptions, ): Effect.Effect => Effect.gen(function* () { const client = yield* SupabaseApiClient; return yield* client.execute<${JSON.stringify(operation.operationName)}>( operationDefinitions.${operation.operationName}, ${inputExpression}, + options, ); }),`; }) @@ -984,29 +1136,9 @@ ${methods} }) .join("\n"); - const executorCases = operations - .map((operation) => { - const { version, methodName } = operation; - const isEmptyInput = - operation.inputSchema.type === "object" && - Object.keys(operation.inputSchema.properties ?? {}).length === 0; - const decodedExpression = `Schema.decodeUnknownEffect(operationDefinitions.${operation.operationName}.inputSchema)(input)`; - const decodedParam = isEmptyInput ? "_decoded" : "decoded"; - const callExpression = isEmptyInput - ? `api.${version}.${methodName}()` - : `api.${version}.${methodName}(decoded)`; - - return ` case ${JSON.stringify(operation.operationName)}: - return ${decodedExpression}.pipe( - Effect.flatMap((${decodedParam}) => ${callExpression}), - );`; - }) - .join("\n"); - - return `import { Effect, Schema } from "effect"; + return `import { Effect } from "effect"; -import type { EffectClient } from "../internal/effect-client.ts"; -import type { SupabaseApiError } from "../internal/client.ts"; +import type { SupabaseApiError, SupabaseApiRequestOptions } from "../internal/client.ts"; import { SupabaseApiClient } from "../internal/client.ts"; import { operationDefinitions } from "./contracts.ts"; @@ -1015,17 +1147,6 @@ ${versionedOperationsSource} } as const; export type GeneratedEffectOperations = typeof versionedEffectOperations; -type GeneratedApiClient = EffectClient; - -export function executeApiClientOperation( - operationId: keyof typeof operationDefinitions, - api: GeneratedApiClient, - input: unknown, -) { - switch (operationId) { -${executorCases} - } -} `; } diff --git a/packages/api/scripts/generate.unit.test.ts b/packages/api/scripts/generate.unit.test.ts index 433e4d10d7..9fb33bef1d 100644 --- a/packages/api/scripts/generate.unit.test.ts +++ b/packages/api/scripts/generate.unit.test.ts @@ -8,6 +8,7 @@ import { extractOperations, normalizeNullableJsonSchema, normalizeQueryParameterSchema, + openStringEnums, operationMethodName, operationVersionFromPath, renderContracts, @@ -132,6 +133,122 @@ describe("generate", () => { }); }); + test("rewrites const to a one-value enum without touching a property named const", () => { + expect( + sanitizeOpenApiSchema({ + type: "object", + properties: { const: { type: "string", const: "fixed" } }, + }), + ).toEqual({ + type: "object", + properties: { const: { type: "string", enum: ["fixed"] } }, + }); + }); + + test("widens multi-value string enums and keeps single-value discriminators closed", () => { + expect( + openStringEnums({ + type: "object", + properties: { + status: { type: "string", enum: ["A", "B"] }, + kind: { type: "string", enum: ["only"] }, + level: { type: "integer", enum: [1, 2] }, + }, + }), + ).toEqual({ + type: "object", + properties: { + status: { + anyOf: [ + { type: "string", enum: ["A", "B"] }, + { type: "string", format: "x-open-string" }, + ], + }, + kind: { type: "string", enum: ["only"] }, + level: { type: "integer", enum: [1, 2] }, + }, + }); + }); + + describe("open response enums", () => { + const statusEnum = { type: "string", enum: ["A", "B"] }; + + function enumFixture(options: { readonly sharedComponent: boolean }): OpenApiDocument { + const responseSchema = { + type: "object", + properties: { + status: statusEnum, + shared: { $ref: "#/components/schemas/Shared" }, + }, + required: ["status", "shared"], + }; + return { + openapi: "3.0.0", + info: { title: "Test API", version: "1.0.0" }, + components: { + schemas: { + Shared: { + type: "object", + properties: { mode: statusEnum }, + required: ["mode"], + }, + }, + }, + paths: { + "/v1/things": { + get: jsonResponseOperation("v1-get-thing", "id", responseSchema), + post: { + operationId: "v1-create-thing", + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + status: statusEnum, + ...(options.sharedComponent + ? { shared: { $ref: "#/components/schemas/Shared" } } + : {}), + }, + required: ["status"], + }, + }, + }, + }, + responses: { "201": { description: "created" } }, + }, + }, + }, + } as unknown as OpenApiDocument; + } + + test("responses accept unknown enum values while requests stay closed", () => { + const document = enumFixture({ sharedComponent: false }); + const source = renderContracts(document, extractOperations(document)); + + expect(source).toContain( + "export const OpenString = Schema.declare((value: unknown): value is string & {} =>", + ); + const outputs = source.slice(source.indexOf("V1GetThingOutput")); + expect(outputs).toContain('Schema.Union([Schema.Literals(["A", "B"]), OpenString])'); + expect(source).toContain('"mode": Schema.Union([Schema.Literals(["A", "B"]), OpenString])'); + const inputs = source.slice( + source.indexOf("V1CreateThingInput"), + source.indexOf("V1GetThingInput"), + ); + expect(inputs).toContain('Schema.Literals(["A", "B"])'); + expect(inputs).not.toContain("OpenString"); + }); + + test("fails when an enum-bearing component is reachable from both a request and a response", () => { + const document = enumFixture({ sharedComponent: true }); + + expect(() => renderContracts(document, extractOperations(document))).toThrow( + 'Schema "Shared" has string enums and is reachable from both request and response schemas', + ); + }); + }); + test("preserves null in nullable anyOf JSON values without an explicit type", () => { expect( renderOpenApiSchema({ @@ -280,7 +397,7 @@ describe("generate", () => { ); }); - test("renderEffectClient emits both version namespaces with the shared method name and a versioned executor case", () => { + test("renderEffectClient emits both version namespaces with the shared method name", () => { const document = twoVersionFixture(); const operations = extractOperations(document); const source = renderEffectClient(operations); @@ -293,7 +410,7 @@ describe("generate", () => { expect(v1Block).toContain("listOrganizationMembers: ("); expect(v2Block).toContain("listOrganizationMembers: ("); - expect(source).toContain("api.v2.listOrganizationMembers(decoded)"); + expect(v2Block).toContain('"v2ListOrganizationMembers"'); }); test("renderContracts includes both versioned operation names and the raw kebab operationIds", () => { diff --git a/packages/api/scripts/openapi-overrides.json b/packages/api/scripts/openapi-overrides.json index f853c25808..0d43e170f2 100644 --- a/packages/api/scripts/openapi-overrides.json +++ b/packages/api/scripts/openapi-overrides.json @@ -2,7 +2,8 @@ { "op": "test", "path": "/components/schemas/CreateProviderResponse_Output/properties/saml/properties/attribute_mapping/required", - "value": ["keys"] + "value": ["keys"], + "$comment": "GoTrue serializes `attribute_mapping.keys` with `omitempty` and the platform passes the body through, so `keys` is absent when empty." }, { "op": "replace", @@ -12,7 +13,8 @@ { "op": "test", "path": "/components/schemas/GetProviderResponse_Output/properties/saml/properties/attribute_mapping/required", - "value": ["keys"] + "value": ["keys"], + "$comment": "GoTrue serializes `attribute_mapping.keys` with `omitempty` and the platform passes the body through, so `keys` is absent when empty." }, { "op": "replace", @@ -22,7 +24,8 @@ { "op": "test", "path": "/components/schemas/ListProvidersResponse_Output/properties/items/items/properties/saml/properties/attribute_mapping/required", - "value": ["keys"] + "value": ["keys"], + "$comment": "GoTrue serializes `attribute_mapping.keys` with `omitempty` and the platform passes the body through, so `keys` is absent when empty." }, { "op": "replace", @@ -32,7 +35,8 @@ { "op": "test", "path": "/components/schemas/UpdateProviderResponse_Output/properties/saml/properties/attribute_mapping/required", - "value": ["keys"] + "value": ["keys"], + "$comment": "GoTrue serializes `attribute_mapping.keys` with `omitempty` and the platform passes the body through, so `keys` is absent when empty." }, { "op": "replace", @@ -42,7 +46,8 @@ { "op": "test", "path": "/components/schemas/DeleteProviderResponse_Output/properties/saml/properties/attribute_mapping/required", - "value": ["keys"] + "value": ["keys"], + "$comment": "GoTrue serializes `attribute_mapping.keys` with `omitempty` and the platform passes the body through, so `keys` is absent when empty." }, { "op": "replace", @@ -52,7 +57,8 @@ { "op": "test", "path": "/components/schemas/UpdateCustomHostnameResponse_Output/required", - "value": ["status", "data"] + "value": ["status", "data"], + "$comment": "Hostnames stored before the upstream fix can lack `status`; the fix covers only new writes." }, { "op": "replace", @@ -61,522 +67,9 @@ }, { "op": "test", - "path": "/components/schemas/AuthConfigResponse_Output/required", - "value": [ - "api_max_request_duration", - "db_max_pool_size", - "db_max_pool_size_unit", - "disable_signup", - "external_anonymous_users_enabled", - "external_apple_additional_client_ids", - "external_apple_client_id", - "external_apple_email_optional", - "external_apple_enabled", - "external_apple_secret", - "external_azure_client_id", - "external_azure_email_optional", - "external_azure_enabled", - "external_azure_secret", - "external_azure_url", - "external_bitbucket_client_id", - "external_bitbucket_email_optional", - "external_bitbucket_enabled", - "external_bitbucket_secret", - "external_discord_client_id", - "external_discord_email_optional", - "external_discord_enabled", - "external_discord_secret", - "external_email_enabled", - "external_facebook_client_id", - "external_facebook_email_optional", - "external_facebook_enabled", - "external_facebook_secret", - "external_figma_client_id", - "external_figma_email_optional", - "external_figma_enabled", - "external_figma_secret", - "external_github_client_id", - "external_github_email_optional", - "external_github_enabled", - "external_github_secret", - "external_gitlab_client_id", - "external_gitlab_email_optional", - "external_gitlab_enabled", - "external_gitlab_secret", - "external_gitlab_url", - "external_google_additional_client_ids", - "external_google_client_id", - "external_google_email_optional", - "external_google_enabled", - "external_google_secret", - "external_google_skip_nonce_check", - "external_kakao_client_id", - "external_kakao_email_optional", - "external_kakao_enabled", - "external_kakao_secret", - "external_keycloak_client_id", - "external_keycloak_email_optional", - "external_keycloak_enabled", - "external_keycloak_secret", - "external_keycloak_url", - "external_linkedin_oidc_client_id", - "external_linkedin_oidc_email_optional", - "external_linkedin_oidc_enabled", - "external_linkedin_oidc_secret", - "external_slack_oidc_client_id", - "external_slack_oidc_email_optional", - "external_slack_oidc_enabled", - "external_slack_oidc_secret", - "external_notion_client_id", - "external_notion_email_optional", - "external_notion_enabled", - "external_notion_secret", - "external_phone_enabled", - "external_slack_client_id", - "external_slack_email_optional", - "external_slack_enabled", - "external_slack_secret", - "external_spotify_client_id", - "external_spotify_email_optional", - "external_spotify_enabled", - "external_spotify_secret", - "external_twitch_client_id", - "external_twitch_email_optional", - "external_twitch_enabled", - "external_twitch_secret", - "external_twitter_client_id", - "external_twitter_email_optional", - "external_twitter_enabled", - "external_twitter_secret", - "external_x_client_id", - "external_x_email_optional", - "external_x_enabled", - "external_x_secret", - "external_workos_client_id", - "external_workos_enabled", - "external_workos_secret", - "external_workos_url", - "external_web3_solana_enabled", - "external_web3_ethereum_enabled", - "external_zoom_client_id", - "external_zoom_email_optional", - "external_zoom_enabled", - "external_zoom_secret", - "hook_custom_access_token_enabled", - "hook_custom_access_token_uri", - "hook_custom_access_token_secrets", - "hook_mfa_verification_attempt_enabled", - "hook_mfa_verification_attempt_uri", - "hook_mfa_verification_attempt_secrets", - "hook_password_verification_attempt_enabled", - "hook_password_verification_attempt_uri", - "hook_password_verification_attempt_secrets", - "hook_send_sms_enabled", - "hook_send_sms_uri", - "hook_send_sms_secrets", - "hook_send_email_enabled", - "hook_send_email_uri", - "hook_send_email_secrets", - "hook_before_user_created_enabled", - "hook_before_user_created_uri", - "hook_before_user_created_secrets", - "hook_after_user_created_enabled", - "hook_after_user_created_uri", - "hook_after_user_created_secrets", - "jwt_exp", - "mailer_allow_unverified_email_sign_ins", - "mailer_autoconfirm", - "mailer_otp_exp", - "mailer_otp_length", - "mailer_secure_email_change_enabled", - "mailer_subjects_confirmation", - "mailer_subjects_email_change", - "mailer_subjects_invite", - "mailer_subjects_magic_link", - "mailer_subjects_reauthentication", - "mailer_subjects_recovery", - "mailer_subjects_password_changed_notification", - "mailer_subjects_email_changed_notification", - "mailer_subjects_phone_changed_notification", - "mailer_subjects_mfa_factor_enrolled_notification", - "mailer_subjects_mfa_factor_unenrolled_notification", - "mailer_subjects_identity_linked_notification", - "mailer_subjects_identity_unlinked_notification", - "mailer_templates_confirmation_content", - "mailer_templates_email_change_content", - "mailer_templates_invite_content", - "mailer_templates_magic_link_content", - "mailer_templates_reauthentication_content", - "mailer_templates_recovery_content", - "mailer_templates_password_changed_notification_content", - "mailer_templates_email_changed_notification_content", - "mailer_templates_phone_changed_notification_content", - "mailer_templates_mfa_factor_enrolled_notification_content", - "mailer_templates_mfa_factor_unenrolled_notification_content", - "mailer_templates_identity_linked_notification_content", - "mailer_templates_identity_unlinked_notification_content", - "mailer_notifications_password_changed_enabled", - "mailer_notifications_email_changed_enabled", - "mailer_notifications_phone_changed_enabled", - "mailer_notifications_mfa_factor_enrolled_enabled", - "mailer_notifications_mfa_factor_unenrolled_enabled", - "mailer_notifications_identity_linked_enabled", - "mailer_notifications_identity_unlinked_enabled", - "mfa_max_enrolled_factors", - "mfa_totp_enroll_enabled", - "mfa_totp_verify_enabled", - "mfa_phone_enroll_enabled", - "mfa_phone_verify_enabled", - "mfa_web_authn_enroll_enabled", - "mfa_web_authn_verify_enabled", - "passkey_enabled", - "webauthn_rp_display_name", - "webauthn_rp_id", - "webauthn_rp_origins", - "mfa_phone_otp_length", - "mfa_phone_template", - "mfa_phone_max_frequency", - "nimbus_oauth_client_id", - "nimbus_oauth_email_optional", - "nimbus_oauth_client_secret", - "password_hibp_enabled", - "password_min_length", - "password_required_characters", - "rate_limit_anonymous_users", - "rate_limit_email_sent", - "rate_limit_sms_sent", - "rate_limit_token_refresh", - "rate_limit_verify", - "rate_limit_otp", - "rate_limit_web3", - "refresh_token_rotation_enabled", - "saml_enabled", - "saml_external_url", - "saml_allow_encrypted_assertions", - "security_sb_forwarded_for_enabled", - "security_captcha_enabled", - "security_captcha_provider", - "security_captcha_secret", - "security_manual_linking_enabled", - "security_refresh_token_reuse_interval", - "security_update_password_require_current_password", - "security_update_password_require_reauthentication", - "sessions_inactivity_timeout", - "sessions_single_per_user", - "sessions_tags", - "sessions_timebox", - "site_url", - "sms_autoconfirm", - "sms_max_frequency", - "sms_messagebird_access_key", - "sms_messagebird_originator", - "sms_otp_exp", - "sms_otp_length", - "sms_provider", - "sms_template", - "sms_test_otp", - "sms_test_otp_valid_until", - "sms_textlocal_api_key", - "sms_textlocal_sender", - "sms_twilio_account_sid", - "sms_twilio_auth_token", - "sms_twilio_content_sid", - "sms_twilio_message_service_sid", - "sms_twilio_verify_account_sid", - "sms_twilio_verify_auth_token", - "sms_twilio_verify_message_service_sid", - "sms_vonage_api_key", - "sms_vonage_api_secret", - "sms_vonage_from", - "smtp_admin_email", - "smtp_host", - "smtp_max_frequency", - "smtp_pass", - "smtp_port", - "smtp_sender_name", - "smtp_user", - "uri_allow_list", - "oauth_server_enabled", - "oauth_server_allow_dynamic_registration", - "oauth_server_authorization_path", - "custom_oauth_enabled", - "custom_oauth_max_providers" - ] - }, - { - "op": "replace", - "path": "/components/schemas/AuthConfigResponse_Output/required", - "value": [ - "api_max_request_duration", - "db_max_pool_size", - "db_max_pool_size_unit", - "disable_signup", - "external_anonymous_users_enabled", - "external_apple_additional_client_ids", - "external_apple_client_id", - "external_apple_email_optional", - "external_apple_enabled", - "external_apple_secret", - "external_azure_client_id", - "external_azure_email_optional", - "external_azure_enabled", - "external_azure_secret", - "external_azure_url", - "external_bitbucket_client_id", - "external_bitbucket_email_optional", - "external_bitbucket_enabled", - "external_bitbucket_secret", - "external_discord_client_id", - "external_discord_email_optional", - "external_discord_enabled", - "external_discord_secret", - "external_email_enabled", - "external_facebook_client_id", - "external_facebook_email_optional", - "external_facebook_enabled", - "external_facebook_secret", - "external_figma_client_id", - "external_figma_email_optional", - "external_figma_enabled", - "external_figma_secret", - "external_github_client_id", - "external_github_email_optional", - "external_github_enabled", - "external_github_secret", - "external_gitlab_client_id", - "external_gitlab_email_optional", - "external_gitlab_enabled", - "external_gitlab_secret", - "external_gitlab_url", - "external_google_additional_client_ids", - "external_google_client_id", - "external_google_email_optional", - "external_google_enabled", - "external_google_secret", - "external_google_skip_nonce_check", - "external_kakao_client_id", - "external_kakao_email_optional", - "external_kakao_enabled", - "external_kakao_secret", - "external_keycloak_client_id", - "external_keycloak_email_optional", - "external_keycloak_enabled", - "external_keycloak_secret", - "external_keycloak_url", - "external_linkedin_oidc_client_id", - "external_linkedin_oidc_email_optional", - "external_linkedin_oidc_enabled", - "external_linkedin_oidc_secret", - "external_slack_oidc_client_id", - "external_slack_oidc_email_optional", - "external_slack_oidc_enabled", - "external_slack_oidc_secret", - "external_notion_client_id", - "external_notion_email_optional", - "external_notion_enabled", - "external_notion_secret", - "external_phone_enabled", - "external_slack_client_id", - "external_slack_email_optional", - "external_slack_enabled", - "external_slack_secret", - "external_spotify_client_id", - "external_spotify_email_optional", - "external_spotify_enabled", - "external_spotify_secret", - "external_twitch_client_id", - "external_twitch_email_optional", - "external_twitch_enabled", - "external_twitch_secret", - "external_twitter_client_id", - "external_twitter_email_optional", - "external_twitter_enabled", - "external_twitter_secret", - "external_x_client_id", - "external_x_email_optional", - "external_x_enabled", - "external_x_secret", - "external_workos_client_id", - "external_workos_enabled", - "external_workos_secret", - "external_workos_url", - "external_web3_solana_enabled", - "external_web3_ethereum_enabled", - "external_zoom_client_id", - "external_zoom_email_optional", - "external_zoom_enabled", - "external_zoom_secret", - "hook_custom_access_token_enabled", - "hook_custom_access_token_uri", - "hook_custom_access_token_secrets", - "hook_mfa_verification_attempt_enabled", - "hook_mfa_verification_attempt_uri", - "hook_mfa_verification_attempt_secrets", - "hook_password_verification_attempt_enabled", - "hook_password_verification_attempt_uri", - "hook_password_verification_attempt_secrets", - "hook_send_sms_enabled", - "hook_send_sms_uri", - "hook_send_sms_secrets", - "hook_send_email_enabled", - "hook_send_email_uri", - "hook_send_email_secrets", - "hook_before_user_created_enabled", - "hook_before_user_created_uri", - "hook_before_user_created_secrets", - "hook_after_user_created_enabled", - "hook_after_user_created_uri", - "hook_after_user_created_secrets", - "jwt_exp", - "mailer_allow_unverified_email_sign_ins", - "mailer_autoconfirm", - "mailer_otp_exp", - "mailer_otp_length", - "mailer_secure_email_change_enabled", - "mailer_subjects_confirmation", - "mailer_subjects_email_change", - "mailer_subjects_invite", - "mailer_subjects_magic_link", - "mailer_subjects_reauthentication", - "mailer_subjects_recovery", - "mailer_subjects_password_changed_notification", - "mailer_subjects_email_changed_notification", - "mailer_subjects_phone_changed_notification", - "mailer_subjects_mfa_factor_enrolled_notification", - "mailer_subjects_mfa_factor_unenrolled_notification", - "mailer_subjects_identity_linked_notification", - "mailer_subjects_identity_unlinked_notification", - "mailer_templates_confirmation_content", - "mailer_templates_email_change_content", - "mailer_templates_invite_content", - "mailer_templates_magic_link_content", - "mailer_templates_reauthentication_content", - "mailer_templates_recovery_content", - "mailer_templates_password_changed_notification_content", - "mailer_templates_email_changed_notification_content", - "mailer_templates_phone_changed_notification_content", - "mailer_templates_mfa_factor_enrolled_notification_content", - "mailer_templates_mfa_factor_unenrolled_notification_content", - "mailer_templates_identity_linked_notification_content", - "mailer_templates_identity_unlinked_notification_content", - "mailer_notifications_password_changed_enabled", - "mailer_notifications_email_changed_enabled", - "mailer_notifications_phone_changed_enabled", - "mailer_notifications_mfa_factor_enrolled_enabled", - "mailer_notifications_mfa_factor_unenrolled_enabled", - "mailer_notifications_identity_linked_enabled", - "mailer_notifications_identity_unlinked_enabled", - "mfa_max_enrolled_factors", - "mfa_totp_enroll_enabled", - "mfa_totp_verify_enabled", - "mfa_phone_enroll_enabled", - "mfa_phone_verify_enabled", - "mfa_web_authn_enroll_enabled", - "mfa_web_authn_verify_enabled", - "passkey_enabled", - "webauthn_rp_display_name", - "webauthn_rp_id", - "webauthn_rp_origins", - "mfa_phone_otp_length", - "mfa_phone_template", - "mfa_phone_max_frequency", - "nimbus_oauth_client_id", - "nimbus_oauth_client_secret", - "password_hibp_enabled", - "password_min_length", - "password_required_characters", - "rate_limit_anonymous_users", - "rate_limit_email_sent", - "rate_limit_sms_sent", - "rate_limit_token_refresh", - "rate_limit_verify", - "rate_limit_otp", - "rate_limit_web3", - "refresh_token_rotation_enabled", - "saml_enabled", - "saml_external_url", - "saml_allow_encrypted_assertions", - "security_sb_forwarded_for_enabled", - "security_captcha_enabled", - "security_captcha_provider", - "security_captcha_secret", - "security_manual_linking_enabled", - "security_refresh_token_reuse_interval", - "security_update_password_require_current_password", - "security_update_password_require_reauthentication", - "sessions_inactivity_timeout", - "sessions_single_per_user", - "sessions_tags", - "sessions_timebox", - "site_url", - "sms_autoconfirm", - "sms_max_frequency", - "sms_messagebird_access_key", - "sms_messagebird_originator", - "sms_otp_exp", - "sms_otp_length", - "sms_provider", - "sms_template", - "sms_test_otp", - "sms_test_otp_valid_until", - "sms_textlocal_api_key", - "sms_textlocal_sender", - "sms_twilio_account_sid", - "sms_twilio_auth_token", - "sms_twilio_content_sid", - "sms_twilio_message_service_sid", - "sms_twilio_verify_account_sid", - "sms_twilio_verify_auth_token", - "sms_twilio_verify_message_service_sid", - "sms_vonage_api_key", - "sms_vonage_api_secret", - "sms_vonage_from", - "smtp_admin_email", - "smtp_host", - "smtp_max_frequency", - "smtp_pass", - "smtp_port", - "smtp_sender_name", - "smtp_user", - "uri_allow_list", - "oauth_server_enabled", - "oauth_server_allow_dynamic_registration", - "oauth_server_authorization_path", - "custom_oauth_enabled", - "custom_oauth_max_providers" - ] - }, - { - "op": "replace", - "path": "/components/schemas/FunctionResponse_Output/properties/import_map_path", - "value": { - "type": "string", - "nullable": true - } - }, - { - "op": "replace", - "path": "/components/schemas/BulkUpdateFunctionResponse_Output/properties/functions/items/properties/import_map_path", - "value": { - "type": "string", - "nullable": true - } - }, - { - "op": "replace", - "path": "/components/schemas/DeployFunctionResponse_Output/properties/import_map_path", - "value": { - "type": "string", - "nullable": true - } - }, - { - "op": "replace", - "path": "/components/schemas/FunctionSlugResponse_Output/properties/import_map_path", - "value": { - "type": "string", - "nullable": true - } + "path": "/components/schemas/UpdateAuthConfigBody/properties/sessions_tags/pattern", + "value": "^\\s*([a-zA-Z0-9_-]+(\\s*,+\\s*)?)*\\s*$", + "$comment": "The platform publishes this regex with nested quantifiers, which backtrack catastrophically on adversarial input; the replacement accepts the same format in linear time." }, { "op": "replace", @@ -584,63 +77,15 @@ "value": "^(?:\\s*|\\s*[a-zA-Z0-9_-]+(?:\\s*,+\\s*[a-zA-Z0-9_-]+)*(?:\\s*,+)?\\s*)$" }, { - "op": "replace", + "op": "test", "path": "/components/schemas/UpdateAuthConfigBody/properties/sms_test_otp/pattern", - "value": "^(?:[0-9]{1,15}=(?:[0-9]+,[0-9]{1,15}=|[0-9]{2,}=)*[0-9]+,?)?$" - }, - { - "op": "remove", - "path": "/paths/~1v2~1projects~1{ref}~1webhooks~1endpoints", - "$comment": "The v2 webhook response schemas use const keywords in the OpenAPI 3.0 document that the Effect schema converter rejects; keep these paths out of the generated client until the converter supports them." - }, - { - "op": "remove", - "path": "/paths/~1v2~1projects~1{ref}~1webhooks~1endpoints~1{id}" - }, - { - "op": "remove", - "path": "/paths/~1v2~1projects~1{ref}~1webhooks~1endpoints~1{id}~1deliveries" - }, - { - "op": "remove", - "path": "/paths/~1v2~1projects~1{ref}~1webhooks~1endpoints~1{id}~1test" - }, - { - "op": "remove", - "path": "/paths/~1v2~1projects~1{ref}~1webhooks~1deliveries~1{id}" - }, - { - "op": "remove", - "path": "/paths/~1v2~1projects~1{ref}~1webhooks~1deliveries~1{id}~1retry" - }, - { - "op": "remove", - "path": "/paths/~1v2~1organizations~1{slug}~1webhooks~1endpoints", - "$comment": "The v2 webhook response schemas use const keywords in the OpenAPI 3.0 document that the Effect schema converter rejects; keep these paths out of the generated client until the converter supports them." - }, - { - "op": "remove", - "path": "/paths/~1v2~1organizations~1{slug}~1webhooks~1endpoints~1{id}" - }, - { - "op": "remove", - "path": "/paths/~1v2~1organizations~1{slug}~1webhooks~1endpoints~1{id}~1deliveries" - }, - { - "op": "remove", - "path": "/paths/~1v2~1organizations~1{slug}~1webhooks~1endpoints~1{id}~1test" - }, - { - "op": "remove", - "path": "/paths/~1v2~1organizations~1{slug}~1webhooks~1deliveries~1{id}" - }, - { - "op": "remove", - "path": "/paths/~1v2~1organizations~1{slug}~1webhooks~1deliveries~1{id}~1retry" + "value": "^([0-9]{1,15}=[0-9]+,?)*$", + "$comment": "The platform publishes this regex with nested quantifiers, which backtrack catastrophically on adversarial input; the replacement accepts the same format in linear time." }, { - "op": "remove", - "path": "/components/schemas/APIErrorObject" + "op": "replace", + "path": "/components/schemas/UpdateAuthConfigBody/properties/sms_test_otp/pattern", + "value": "^(?:[0-9]{1,15}=(?:[0-9]+,[0-9]{1,15}=|[0-9]{2,}=)*[0-9]+,?)?$" }, { "op": "test", @@ -648,7 +93,8 @@ "value": { "deprecated": true, "type": "null" - } + }, + "$comment": "CLI-2180: the public spec deliberately hides this field (upstream marks it deprecated/null) even though POST /v1/projects accepts it; enum mirrors CreateBranchBody.release_channel in the same spec." }, { "op": "replace", @@ -657,8 +103,7 @@ "type": "string", "enum": ["internal", "alpha", "beta", "ga", "withdrawn", "preview"], "description": "Release channel. If not provided, GA will be used." - }, - "$comment": "CLI-2180: the public spec deliberately hides this field (upstream marks it deprecated/null) even though POST /v1/projects accepts it; enum mirrors CreateBranchBody.release_channel in the same spec." + } }, { "op": "test", @@ -666,7 +111,8 @@ "value": { "deprecated": true, "type": "null" - } + }, + "$comment": "CLI-2180: the public spec deliberately hides this field (upstream marks it deprecated/null) even though POST /v1/projects accepts it; enum mirrors CreateBranchBody.postgres_engine in the same spec." }, { "op": "replace", @@ -675,7 +121,36 @@ "type": "string", "enum": ["15", "17", "17-oriole"], "description": "Postgres engine version. If not provided, the latest version will be used." - }, - "$comment": "CLI-2180: the public spec deliberately hides this field (upstream marks it deprecated/null) even though POST /v1/projects accepts it; enum mirrors CreateBranchBody.postgres_engine in the same spec." + } + }, + { + "op": "add", + "path": "/components/schemas/SnippetList_Output/properties/data/items/properties/owner/nullable", + "value": true, + "$comment": "The platform LEFT JOINs the owner and last-updated-by users, so these are null when the user row is gone." + }, + { + "op": "add", + "path": "/components/schemas/SnippetList_Output/properties/data/items/properties/updated_by/nullable", + "value": true, + "$comment": "The platform LEFT JOINs the owner and last-updated-by users, so these are null when the user row is gone." + }, + { + "op": "add", + "path": "/components/schemas/SnippetResponse_Output/properties/owner/nullable", + "value": true, + "$comment": "The platform LEFT JOINs the owner and last-updated-by users, so these are null when the user row is gone." + }, + { + "op": "add", + "path": "/components/schemas/SnippetResponse_Output/properties/updated_by/nullable", + "value": true, + "$comment": "The platform LEFT JOINs the owner and last-updated-by users, so these are null when the user row is gone." + }, + { + "op": "add", + "path": "/components/schemas/SnippetResponse_Output/properties/content/nullable", + "value": true, + "$comment": "The platform returns a null `content` for a snippet that has no stored content." } ] diff --git a/packages/api/src/effect.ts b/packages/api/src/effect.ts index e86a7d4ed4..d3633d2940 100644 --- a/packages/api/src/effect.ts +++ b/packages/api/src/effect.ts @@ -14,6 +14,8 @@ import { export type { SupabaseApiError, SupabaseApiInputErrorSource, + SupabaseApiRawRequestOptions, + SupabaseApiRequestOptions, SupabaseApiRetryOptions, } from "./internal/client.ts"; export { @@ -33,7 +35,6 @@ export { operationDefinitions, } from "./generated/contracts.ts"; export * from "./generated/contracts.ts"; -export { executeApiClientOperation } from "./generated/effect-client.ts"; export const makeApiClient = (config: SupabaseApiConfig = {}, options?: SupabaseApiClientOptions) => Effect.map(makeSupabaseApiClient(config, options), (client) => ({ diff --git a/packages/api/src/effect.unit.test.ts b/packages/api/src/effect.unit.test.ts index 466a97815a..93249f8096 100644 --- a/packages/api/src/effect.unit.test.ts +++ b/packages/api/src/effect.unit.test.ts @@ -167,13 +167,40 @@ describe("makeApiClient", () => { ref: "abcdefghijklmnopqrst", function_slug: "hello-world", }, - { Accept: "multipart/form-data" }, + { headers: { Accept: "multipart/form-data" } }, ), ); expect(accept).toBe("multipart/form-data"); }); + test("forwards per-request options through generated typed methods", async () => { + let seen: { url: string; authorization: string | undefined } | undefined; + + const client = await Effect.runPromise( + makeApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + seen = { url: request.url, authorization: request.headers.authorization }; + return Effect.succeed(jsonResponse(request, 200, [])); + }), + ), + ), + ); + + await Effect.runPromise( + client.v1.listAllProjects( + {}, + { baseUrl: "https://api.example.test", accessToken: "per-request-token" }, + ), + ); + + expect(seen).toEqual({ + url: "https://api.example.test/v1/projects", + authorization: "Bearer per-request-token", + }); + }); + test("uses the default API URL when baseUrl is omitted", async () => { const seenRequests: Array<{ method: string; url: string }> = []; diff --git a/packages/api/src/generated/contracts.ts b/packages/api/src/generated/contracts.ts index c39cbf9be9..74b70cb40a 100644 --- a/packages/api/src/generated/contracts.ts +++ b/packages/api/src/generated/contracts.ts @@ -1,9 +1,13 @@ import * as Schema from "effect/Schema"; +// open string helper +export const OpenString = Schema.declare( + (value: unknown): value is string & {} => typeof value === "string", +); // non-recursive definitions export const SupavisorConfigResponse_Output = Schema.Struct({ identifier: Schema.String, - database_type: Schema.Literals(["PRIMARY", "READ_REPLICA"]), + database_type: Schema.Union([Schema.Literals(["PRIMARY", "READ_REPLICA"]), OpenString]), is_using_scram_auth: Schema.Boolean, db_user: Schema.String, db_host: Schema.String, @@ -49,13 +53,16 @@ export const SupavisorConfigResponse_Output = Schema.Struct({ ), Schema.Null, ]), - pool_mode: Schema.Literals(["transaction", "session"]), + pool_mode: Schema.Union([Schema.Literals(["transaction", "session"]), OpenString]), }).annotate({ identifier: "SupavisorConfigResponse_Output" }); export const ApiKeyResponse_Output = Schema.Struct({ api_key: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), type: Schema.optionalKey( - Schema.Union([Schema.Literals(["legacy", "publishable", "secret"]), Schema.Null]), + Schema.Union([ + Schema.Union([Schema.Literals(["legacy", "publishable", "secret"]), Schema.Null]), + OpenString, + ]), ), prefix: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), name: Schema.String, @@ -75,18 +82,21 @@ export const ApiKeyResponse_Output = Schema.Struct({ ), }).annotate({ identifier: "ApiKeyResponse_Output" }); export const V1ServiceHealthResponse_Output = Schema.Struct({ - name: Schema.Literals([ - "auth", - "db", - "db_postgres_user", - "pooler", - "realtime", - "rest", - "storage", - "pg_bouncer", + name: Schema.Union([ + Schema.Literals([ + "auth", + "db", + "db_postgres_user", + "pooler", + "realtime", + "rest", + "storage", + "pg_bouncer", + ]), + OpenString, ]), healthy: Schema.Boolean.annotate({ description: "Deprecated. Use `status` instead." }), - status: Schema.Literals(["COMING_UP", "ACTIVE_HEALTHY", "UNHEALTHY"]), + status: Schema.Union([Schema.Literals(["COMING_UP", "ACTIVE_HEALTHY", "UNHEALTHY"]), OpenString]), info: Schema.optionalKey( Schema.Union([ Schema.Struct({ @@ -152,16 +162,19 @@ export const BranchResponse_Output = Schema.Struct({ }).check(Schema.isFinite().annotate({ expected: "a finite number" })), ), persistent: Schema.Boolean, - status: Schema.Literals([ - "CREATING_PROJECT", - "RUNNING_MIGRATIONS", - "MIGRATIONS_PASSED", - "MIGRATIONS_FAILED", - "FUNCTIONS_DEPLOYED", - "FUNCTIONS_FAILED", - ]).annotate({ - description: "This field is deprecated. List action runs to get branch status instead.", - }), + status: Schema.Union([ + Schema.Literals([ + "CREATING_PROJECT", + "RUNNING_MIGRATIONS", + "MIGRATIONS_PASSED", + "MIGRATIONS_FAILED", + "FUNCTIONS_DEPLOYED", + "FUNCTIONS_FAILED", + ]).annotate({ + description: "This field is deprecated. List action runs to get branch status instead.", + }), + OpenString, + ]), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), review_requested_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), @@ -169,22 +182,25 @@ export const BranchResponse_Output = Schema.Struct({ notify_url: Schema.optionalKey(Schema.String.annotate({ format: "uri" })), deletion_scheduled_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), preview_project_status: Schema.optionalKey( - Schema.Literals([ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING", + Schema.Union([ + Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]), + OpenString, ]), ), }).annotate({ identifier: "BranchResponse_Output" }); @@ -200,7 +216,7 @@ export const FunctionResponse_Output = Schema.Struct({ id: Schema.String, slug: Schema.String, name: Schema.String, - status: Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), + status: Schema.Union([Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), OpenString]), version: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -239,7 +255,7 @@ export const FunctionResponse_Output = Schema.Struct({ verify_jwt: Schema.optionalKey(Schema.Boolean), import_map: Schema.optionalKey(Schema.Boolean), entrypoint_path: Schema.optionalKey(Schema.String), - import_map_path: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + import_map_path: Schema.optionalKey(Schema.String), ezbr_sha256: Schema.optionalKey(Schema.String), }).annotate({ identifier: "FunctionResponse_Output" }); export const OrganizationResponseV1_Output = Schema.Struct({ @@ -276,22 +292,25 @@ export const V1ProjectWithDatabaseResponse_Output = Schema.Struct({ name: Schema.String.annotate({ description: "Name of your project" }), region: Schema.String.annotate({ description: "Region of your project" }), created_at: Schema.String.annotate({ description: "Creation timestamp" }), - status: Schema.Literals([ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING", + status: Schema.Union([ + Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]), + OpenString, ]), database: Schema.Struct({ host: Schema.String.annotate({ description: "Database host" }), @@ -468,12 +487,15 @@ export const V1ActivateCustomHostnameInput = Schema.Struct({ }); export const V1ActivateCustomHostnameOutput = Schema.Struct({ status: Schema.optionalKey( - Schema.Literals([ - "1_not_started", - "2_initiated", - "3_challenge_verified", - "4_origin_setup_completed", - "5_services_reconfigured", + Schema.Union([ + Schema.Literals([ + "1_not_started", + "2_initiated", + "3_challenge_verified", + "4_origin_setup_completed", + "5_services_reconfigured", + ]), + OpenString, ]), ), custom_hostname: Schema.optionalKey(Schema.String), @@ -839,7 +861,7 @@ export const V1BulkUpdateFunctionsOutput = Schema.Struct({ id: Schema.String, slug: Schema.String, name: Schema.String, - status: Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), + status: Schema.Union([Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), OpenString]), version: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -878,7 +900,7 @@ export const V1BulkUpdateFunctionsOutput = Schema.Struct({ verify_jwt: Schema.optionalKey(Schema.Boolean), import_map: Schema.optionalKey(Schema.Boolean), entrypoint_path: Schema.optionalKey(Schema.String), - import_map_path: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + import_map_path: Schema.optionalKey(Schema.String), ezbr_sha256: Schema.optionalKey(Schema.String), }), ), @@ -1035,16 +1057,19 @@ export const V1CreateABranchOutput = Schema.Struct({ }).check(Schema.isFinite().annotate({ expected: "a finite number" })), ), persistent: Schema.Boolean, - status: Schema.Literals([ - "CREATING_PROJECT", - "RUNNING_MIGRATIONS", - "MIGRATIONS_PASSED", - "MIGRATIONS_FAILED", - "FUNCTIONS_DEPLOYED", - "FUNCTIONS_FAILED", - ]).annotate({ - description: "This field is deprecated. List action runs to get branch status instead.", - }), + status: Schema.Union([ + Schema.Literals([ + "CREATING_PROJECT", + "RUNNING_MIGRATIONS", + "MIGRATIONS_PASSED", + "MIGRATIONS_FAILED", + "FUNCTIONS_DEPLOYED", + "FUNCTIONS_FAILED", + ]).annotate({ + description: "This field is deprecated. List action runs to get branch status instead.", + }), + OpenString, + ]), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), review_requested_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), @@ -1052,22 +1077,25 @@ export const V1CreateABranchOutput = Schema.Struct({ notify_url: Schema.optionalKey(Schema.String.annotate({ format: "uri" })), deletion_scheduled_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), preview_project_status: Schema.optionalKey( - Schema.Literals([ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING", + Schema.Union([ + Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]), + OpenString, ]), ), }); @@ -1102,7 +1130,7 @@ export const V1CreateAFunctionOutput = Schema.Struct({ id: Schema.String, slug: Schema.String, name: Schema.String, - status: Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), + status: Schema.Union([Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), OpenString]), version: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -1141,7 +1169,7 @@ export const V1CreateAFunctionOutput = Schema.Struct({ verify_jwt: Schema.optionalKey(Schema.Boolean), import_map: Schema.optionalKey(Schema.Boolean), entrypoint_path: Schema.optionalKey(Schema.String), - import_map_path: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + import_map_path: Schema.optionalKey(Schema.String), ezbr_sha256: Schema.optionalKey(Schema.String), }); export const V1CreateAProjectInput = Schema.Struct({ @@ -1307,22 +1335,25 @@ export const V1CreateAProjectOutput = Schema.Struct({ name: Schema.String.annotate({ description: "Name of your project" }), region: Schema.String.annotate({ description: "Region of your project" }), created_at: Schema.String.annotate({ description: "Creation timestamp" }), - status: Schema.Literals([ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING", + status: Schema.Union([ + Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]), + OpenString, ]), }); export const V1CreateASsoProviderInput = Schema.Struct({ @@ -1386,11 +1417,14 @@ export const V1CreateASsoProviderOutput = Schema.Struct({ }), ), name_id_format: Schema.optionalKey( - Schema.Literals([ - "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + Schema.Union([ + Schema.Literals([ + "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + ]), + OpenString, ]), ), }), @@ -1446,8 +1480,11 @@ export const V1CreateLegacySigningKeyOutput = Schema.Struct({ "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", }), ), - algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), - status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + algorithm: Schema.Union([Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), OpenString]), + status: Schema.Union([ + Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + OpenString, + ]), public_jwk: Schema.Json.annotate({ expected: "JSON value" }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), @@ -1521,7 +1558,10 @@ export const V1CreateProjectApiKeyOutput = Schema.Struct({ api_key: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), type: Schema.optionalKey( - Schema.Union([Schema.Literals(["legacy", "publishable", "secret"]), Schema.Null]), + Schema.Union([ + Schema.Union([Schema.Literals(["legacy", "publishable", "secret"]), Schema.Null]), + OpenString, + ]), ), prefix: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), name: Schema.String, @@ -1736,8 +1776,11 @@ export const V1CreateProjectSigningKeyOutput = Schema.Struct({ "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", }), ), - algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), - status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + algorithm: Schema.Union([Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), OpenString]), + status: Schema.Union([ + Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + OpenString, + ]), public_jwk: Schema.Json.annotate({ expected: "JSON value" }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), @@ -1797,7 +1840,10 @@ export const V1CreateRestorePointInput = Schema.Struct({ }); export const V1CreateRestorePointOutput = Schema.Struct({ name: Schema.String, - status: Schema.Literals(["AVAILABLE", "PENDING", "REMOVED", "FAILED"]), + status: Schema.Union([ + Schema.Literals(["AVAILABLE", "PENDING", "REMOVED", "FAILED"]), + OpenString, + ]), completed_on: Schema.Union([Schema.String.annotate({ format: "date-time" }), Schema.Null]), }); export const V1DeactivateVanitySubdomainConfigInput = Schema.Struct({ @@ -1949,11 +1995,14 @@ export const V1DeleteASsoProviderOutput = Schema.Struct({ }), ), name_id_format: Schema.optionalKey( - Schema.Literals([ - "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + Schema.Union([ + Schema.Literals([ + "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + ]), + OpenString, ]), ), }), @@ -2085,7 +2134,10 @@ export const V1DeleteProjectApiKeyOutput = Schema.Struct({ api_key: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), type: Schema.optionalKey( - Schema.Union([Schema.Literals(["legacy", "publishable", "secret"]), Schema.Null]), + Schema.Union([ + Schema.Union([Schema.Literals(["legacy", "publishable", "secret"]), Schema.Null]), + OpenString, + ]), ), prefix: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), name: Schema.String, @@ -2197,7 +2249,7 @@ export const V1DeployAFunctionOutput = Schema.Struct({ id: Schema.String, slug: Schema.String, name: Schema.String, - status: Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), + status: Schema.Union([Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), OpenString]), version: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -2240,7 +2292,7 @@ export const V1DeployAFunctionOutput = Schema.Struct({ verify_jwt: Schema.optionalKey(Schema.Boolean), import_map: Schema.optionalKey(Schema.Boolean), entrypoint_path: Schema.optionalKey(Schema.String), - import_map_path: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + import_map_path: Schema.optionalKey(Schema.String), ezbr_sha256: Schema.optionalKey(Schema.String), }); export const V1DiffABranchInput = Schema.Struct({ @@ -2441,16 +2493,19 @@ export const V1GetABranchOutput = Schema.Struct({ }).check(Schema.isFinite().annotate({ expected: "a finite number" })), ), persistent: Schema.Boolean, - status: Schema.Literals([ - "CREATING_PROJECT", - "RUNNING_MIGRATIONS", - "MIGRATIONS_PASSED", - "MIGRATIONS_FAILED", - "FUNCTIONS_DEPLOYED", - "FUNCTIONS_FAILED", - ]).annotate({ - description: "This field is deprecated. List action runs to get branch status instead.", - }), + status: Schema.Union([ + Schema.Literals([ + "CREATING_PROJECT", + "RUNNING_MIGRATIONS", + "MIGRATIONS_PASSED", + "MIGRATIONS_FAILED", + "FUNCTIONS_DEPLOYED", + "FUNCTIONS_FAILED", + ]).annotate({ + description: "This field is deprecated. List action runs to get branch status instead.", + }), + OpenString, + ]), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), review_requested_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), @@ -2458,22 +2513,25 @@ export const V1GetABranchOutput = Schema.Struct({ notify_url: Schema.optionalKey(Schema.String.annotate({ format: "uri" })), deletion_scheduled_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), preview_project_status: Schema.optionalKey( - Schema.Literals([ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING", + Schema.Union([ + Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]), + OpenString, ]), ), }); @@ -2509,22 +2567,25 @@ export const V1GetABranchConfigOutput = Schema.Struct({ postgres_version: Schema.String, postgres_engine: Schema.String, release_channel: Schema.String, - status: Schema.Literals([ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING", + status: Schema.Union([ + Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]), + OpenString, ]), db_host: Schema.String, db_port: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) @@ -2560,7 +2621,7 @@ export const V1GetAFunctionOutput = Schema.Struct({ id: Schema.String, slug: Schema.String, name: Schema.String, - status: Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), + status: Schema.Union([Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), OpenString]), version: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -2599,7 +2660,7 @@ export const V1GetAFunctionOutput = Schema.Struct({ verify_jwt: Schema.optionalKey(Schema.Boolean), import_map: Schema.optionalKey(Schema.Boolean), entrypoint_path: Schema.optionalKey(Schema.String), - import_map_path: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + import_map_path: Schema.optionalKey(Schema.String), ezbr_sha256: Schema.optionalKey(Schema.String), }); export const V1GetAFunctionBodyInput = Schema.Struct({ @@ -2665,31 +2726,40 @@ export const V1GetASnippetOutput = Schema.Struct({ inserted_at: Schema.String, updated_at: Schema.String, type: Schema.Literal("sql"), - visibility: Schema.Literals(["user", "project", "org", "public"]), + visibility: Schema.Union([Schema.Literals(["user", "project", "org", "public"]), OpenString]), name: Schema.String, description: Schema.Union([Schema.String, Schema.Null]), project: Schema.Struct({ id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), name: Schema.String, }), - owner: Schema.Struct({ - id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), - username: Schema.String, - }), - updated_by: Schema.Struct({ - id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), - username: Schema.String, - }), + owner: Schema.Union([ + Schema.Struct({ + id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + username: Schema.String, + }), + Schema.Null, + ]), + updated_by: Schema.Union([ + Schema.Struct({ + id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + username: Schema.String, + }), + Schema.Null, + ]), favorite: Schema.Boolean, - content: Schema.Struct({ - favorite: Schema.optionalKey( - Schema.Boolean.annotate({ - description: "Deprecated: Rely on root-level favorite property instead.", - }), - ), - schema_version: Schema.optionalKey(Schema.String), - sql: Schema.String, - }), + content: Schema.Union([ + Schema.Struct({ + favorite: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Deprecated: Rely on root-level favorite property instead.", + }), + ), + schema_version: Schema.optionalKey(Schema.String), + sql: Schema.String, + }), + Schema.Null, + ]), }); export const V1GetASsoProviderInput = Schema.Struct({ ref: Schema.String.check( @@ -2738,11 +2808,14 @@ export const V1GetASsoProviderOutput = Schema.Struct({ }), ), name_id_format: Schema.optionalKey( - Schema.Literals([ - "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + Schema.Union([ + Schema.Literals([ + "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + ]), + OpenString, ]), ), }), @@ -2778,15 +2851,21 @@ export const V1GetActionRunOutput = Schema.Struct({ branch_id: Schema.String, run_steps: Schema.Array( Schema.Struct({ - name: Schema.Literals(["clone", "pull", "health", "configure", "migrate", "seed", "deploy"]), - status: Schema.Literals([ - "CREATED", - "DEAD", - "EXITED", - "PAUSED", - "REMOVING", - "RESTARTING", - "RUNNING", + name: Schema.Union([ + Schema.Literals(["clone", "pull", "health", "configure", "migrate", "seed", "deploy"]), + OpenString, + ]), + status: Schema.Union([ + Schema.Literals([ + "CREATED", + "DEAD", + "EXITED", + "PAUSED", + "REMOVING", + "RESTARTING", + "RUNNING", + ]), + OpenString, ]), created_at: Schema.String, updated_at: Schema.String, @@ -2860,72 +2939,83 @@ export const V1GetAllProjectsForOrganizationOutput = Schema.Struct({ cloud_provider: Schema.String, region: Schema.String, is_branch: Schema.Boolean, - status: Schema.Literals([ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING", + status: Schema.Union([ + Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]), + OpenString, ]), inserted_at: Schema.String, databases: Schema.Array( Schema.Struct({ infra_compute_size: Schema.optionalKey( - Schema.Literals([ - "pico", - "nano", - "micro", - "small", - "medium", - "large", - "xlarge", - "2xlarge", - "4xlarge", - "8xlarge", - "12xlarge", - "16xlarge", - "24xlarge", - "24xlarge_optimized_memory", - "24xlarge_optimized_cpu", - "24xlarge_high_memory", - "48xlarge", - "48xlarge_optimized_memory", - "48xlarge_optimized_cpu", - "48xlarge_high_memory", + Schema.Union([ + Schema.Literals([ + "pico", + "nano", + "micro", + "small", + "medium", + "large", + "xlarge", + "2xlarge", + "4xlarge", + "8xlarge", + "12xlarge", + "16xlarge", + "24xlarge", + "24xlarge_optimized_memory", + "24xlarge_optimized_cpu", + "24xlarge_high_memory", + "48xlarge", + "48xlarge_optimized_memory", + "48xlarge_optimized_cpu", + "48xlarge_high_memory", + ]), + OpenString, ]), ), region: Schema.String, - status: Schema.Literals([ - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UNKNOWN", - "INIT_READ_REPLICA", - "INIT_READ_REPLICA_FAILED", - "RESTARTING", - "RESIZING", + status: Schema.Union([ + Schema.Literals([ + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UNKNOWN", + "INIT_READ_REPLICA", + "INIT_READ_REPLICA_FAILED", + "RESTARTING", + "RESIZING", + ]), + OpenString, ]), cloud_provider: Schema.String, identifier: Schema.String, - type: Schema.Literals(["PRIMARY", "READ_REPLICA"]), + type: Schema.Union([Schema.Literals(["PRIMARY", "READ_REPLICA"]), OpenString]), disk_volume_size_gb: Schema.optionalKey( Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), - disk_type: Schema.optionalKey(Schema.Literals(["gp3", "io2"])), + disk_type: Schema.optionalKey( + Schema.Union([Schema.Literals(["gp3", "io2"]), OpenString]), + ), disk_throughput_mbps: Schema.optionalKey( Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), @@ -2956,16 +3046,24 @@ export const V1GetAnOrganizationInput = Schema.Struct({ export const V1GetAnOrganizationOutput = Schema.Struct({ id: Schema.String, name: Schema.String, - plan: Schema.optionalKey(Schema.Literals(["free", "pro", "team", "enterprise", "platform"])), + plan: Schema.optionalKey( + Schema.Union([Schema.Literals(["free", "pro", "team", "enterprise", "platform"]), OpenString]), + ), opt_in_tags: Schema.Array( - Schema.Literals([ - "AI_SQL_GENERATOR_OPT_IN", - "AI_DATA_GENERATOR_OPT_IN", - "AI_LOG_GENERATOR_OPT_IN", + Schema.Union([ + Schema.Literals([ + "AI_SQL_GENERATOR_OPT_IN", + "AI_DATA_GENERATOR_OPT_IN", + "AI_LOG_GENERATOR_OPT_IN", + ]), + OpenString, ]), ), allowed_release_channels: Schema.Array( - Schema.Literals(["internal", "alpha", "beta", "ga", "withdrawn", "preview"]), + Schema.Union([ + Schema.Literals(["internal", "alpha", "beta", "ga", "withdrawn", "preview"]), + OpenString, + ]), ), }); export const V1GetAuthServiceConfigInput = Schema.Struct({ @@ -3010,7 +3108,10 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ ), Schema.Null, ]), - db_max_pool_size_unit: Schema.Union([Schema.Literals(["connections", "percent"]), Schema.Null]), + db_max_pool_size_unit: Schema.Union([ + Schema.Union([Schema.Literals(["connections", "percent"]), Schema.Null]), + OpenString, + ]), disable_signup: Schema.Union([Schema.Boolean, Schema.Null]), external_anonymous_users_enabled: Schema.Union([Schema.Boolean, Schema.Null]), external_apple_additional_client_ids: Schema.Union([Schema.String, Schema.Null]), @@ -3266,7 +3367,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), nimbus_oauth_client_id: Schema.Union([Schema.String, Schema.Null]), - nimbus_oauth_email_optional: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), + nimbus_oauth_email_optional: Schema.Union([Schema.Boolean, Schema.Null]), nimbus_oauth_client_secret: Schema.Union([Schema.String, Schema.Null]), password_hibp_enabled: Schema.Union([Schema.Boolean, Schema.Null]), password_min_length: Schema.Union([ @@ -3284,13 +3385,16 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), password_required_characters: Schema.Union([ - Schema.Literals([ - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", - "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", - "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\\\:\"|<>?,./`~", - "", + Schema.Union([ + Schema.Literals([ + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", + "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", + "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\\\:\"|<>?,./`~", + "", + ]), + Schema.Null, ]), - Schema.Null, + OpenString, ]), rate_limit_anonymous_users: Schema.Union([ Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) @@ -3397,8 +3501,8 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ security_sb_forwarded_for_enabled: Schema.Union([Schema.Boolean, Schema.Null]), security_captcha_enabled: Schema.Union([Schema.Boolean, Schema.Null]), security_captcha_provider: Schema.Union([ - Schema.Literals(["turnstile", "hcaptcha"]), - Schema.Null, + Schema.Union([Schema.Literals(["turnstile", "hcaptcha"]), Schema.Null]), + OpenString, ]), security_captcha_secret: Schema.Union([Schema.String, Schema.Null]), security_manual_linking_enabled: Schema.Union([Schema.Boolean, Schema.Null]), @@ -3472,8 +3576,11 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ }), ), sms_provider: Schema.Union([ - Schema.Literals(["messagebird", "textlocal", "twilio", "twilio_verify", "vonage"]), - Schema.Null, + Schema.Union([ + Schema.Literals(["messagebird", "textlocal", "twilio", "twilio_verify", "vonage"]), + Schema.Null, + ]), + OpenString, ]), sms_template: Schema.Union([Schema.String, Schema.Null]), sms_test_otp: Schema.Union([Schema.String, Schema.Null]), @@ -3576,35 +3683,40 @@ export const V1GetAvailableRegionsOutput = Schema.Struct({ recommendations: Schema.Struct({ smartGroup: Schema.Struct({ name: Schema.String, - code: Schema.Literals(["americas", "emea", "apac"]), + code: Schema.Union([Schema.Literals(["americas", "emea", "apac"]), OpenString]), type: Schema.Literal("smartGroup"), }), specific: Schema.Array( Schema.Struct({ name: Schema.String, - code: Schema.Literals([ - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", - "ap-southeast-1", - "ap-northeast-1", - "ap-northeast-2", - "ap-east-1", - "ap-southeast-2", - "eu-west-1", - "eu-west-2", - "eu-west-3", - "eu-north-1", - "eu-central-1", - "eu-central-2", - "ca-central-1", - "ap-south-1", - "sa-east-1", + code: Schema.Union([ + Schema.Literals([ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ap-southeast-1", + "ap-northeast-1", + "ap-northeast-2", + "ap-east-1", + "ap-southeast-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-north-1", + "eu-central-1", + "eu-central-2", + "ca-central-1", + "ap-south-1", + "sa-east-1", + ]), + OpenString, ]), type: Schema.Literal("specific"), - provider: Schema.Literals(["AWS", "AWS_K8S", "AWS_NIMBUS"]), - status: Schema.optionalKey(Schema.Literals(["capacity", "other"])), + provider: Schema.Union([Schema.Literals(["AWS", "AWS_K8S", "AWS_NIMBUS"]), OpenString]), + status: Schema.optionalKey( + Schema.Union([Schema.Literals(["capacity", "other"]), OpenString]), + ), }), ), }), @@ -3612,36 +3724,41 @@ export const V1GetAvailableRegionsOutput = Schema.Struct({ smartGroup: Schema.Array( Schema.Struct({ name: Schema.String, - code: Schema.Literals(["americas", "emea", "apac"]), + code: Schema.Union([Schema.Literals(["americas", "emea", "apac"]), OpenString]), type: Schema.Literal("smartGroup"), }), ), specific: Schema.Array( Schema.Struct({ name: Schema.String, - code: Schema.Literals([ - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", - "ap-southeast-1", - "ap-northeast-1", - "ap-northeast-2", - "ap-east-1", - "ap-southeast-2", - "eu-west-1", - "eu-west-2", - "eu-west-3", - "eu-north-1", - "eu-central-1", - "eu-central-2", - "ca-central-1", - "ap-south-1", - "sa-east-1", + code: Schema.Union([ + Schema.Literals([ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ap-southeast-1", + "ap-northeast-1", + "ap-northeast-2", + "ap-east-1", + "ap-southeast-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-north-1", + "eu-central-1", + "eu-central-2", + "ca-central-1", + "ap-south-1", + "sa-east-1", + ]), + OpenString, ]), type: Schema.Literal("specific"), - provider: Schema.Literals(["AWS", "AWS_K8S", "AWS_NIMBUS"]), - status: Schema.optionalKey(Schema.Literals(["capacity", "other"])), + provider: Schema.Union([Schema.Literals(["AWS", "AWS_K8S", "AWS_NIMBUS"]), OpenString]), + status: Schema.optionalKey( + Schema.Union([Schema.Literals(["capacity", "other"]), OpenString]), + ), }), ), }), @@ -3813,12 +3930,15 @@ export const V1GetHostnameConfigInput = Schema.Struct({ }); export const V1GetHostnameConfigOutput = Schema.Struct({ status: Schema.optionalKey( - Schema.Literals([ - "1_not_started", - "2_initiated", - "3_challenge_verified", - "4_origin_setup_completed", - "5_services_reconfigured", + Schema.Union([ + Schema.Literals([ + "1_not_started", + "2_initiated", + "3_challenge_verified", + "4_origin_setup_completed", + "5_services_reconfigured", + ]), + OpenString, ]), ), custom_hostname: Schema.optionalKey(Schema.String), @@ -3949,16 +4069,19 @@ export const V1GetJitAccessConfigInput = Schema.Struct({ export const V1GetJitAccessConfigOutput = Schema.Union( [ Schema.Struct({ - state: Schema.Literals(["enabled", "disabled"]), + state: Schema.Union([Schema.Literals(["enabled", "disabled"]), OpenString]), appliedSuccessfully: Schema.optionalKey(Schema.Boolean), }), Schema.Struct({ state: Schema.Literal("unavailable"), - unavailableReason: Schema.Literals([ - "platform_unsupported", - "postgres_upgrade_required", - "ssl_enforcement_required", - "temporarily_unavailable", + unavailableReason: Schema.Union([ + Schema.Literals([ + "platform_unsupported", + "postgres_upgrade_required", + "ssl_enforcement_required", + "temporarily_unavailable", + ]), + OpenString, ]), }), ], @@ -3989,8 +4112,11 @@ export const V1GetLegacySigningKeyOutput = Schema.Struct({ "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", }), ), - algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), - status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + algorithm: Schema.Union([Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), OpenString]), + status: Schema.Union([ + Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + OpenString, + ]), public_jwk: Schema.Json.annotate({ expected: "JSON value" }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), @@ -4009,7 +4135,7 @@ export const V1GetNetworkRestrictionsInput = Schema.Struct({ ), }); export const V1GetNetworkRestrictionsOutput = Schema.Struct({ - entitlement: Schema.Literals(["disallowed", "allowed"]), + entitlement: Schema.Union([Schema.Literals(["disallowed", "allowed"]), OpenString]), config: Schema.Struct({ dbAllowedCidrs: Schema.optionalKey(Schema.Array(Schema.String)), dbAllowedCidrsV6: Schema.optionalKey(Schema.Array(Schema.String)), @@ -4026,7 +4152,7 @@ export const V1GetNetworkRestrictionsOutput = Schema.Struct({ "Populated when a new config has been received, but not registered as successfully applied to a project.", }), ), - status: Schema.Literals(["stored", "applied"]), + status: Schema.Union([Schema.Literals(["stored", "applied"]), OpenString]), updated_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), applied_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), }); @@ -4041,76 +4167,79 @@ export const V1GetOrganizationEntitlementsOutput = Schema.Struct({ entitlements: Schema.Array( Schema.Struct({ feature: Schema.Struct({ - key: Schema.Literals([ - "instances.compute_update_available_sizes", - "instances.read_replicas", - "instances.disk_modifications", - "instances.high_availability", - "instances.orioledb", - "replication.etl", - "storage.max_file_size", - "storage.max_file_size.configurable", - "storage.image_transformations", - "storage.vector_buckets", - "storage.iceberg_catalog", - "storage.purge_cache", - "security.audit_logs_days", - "security.questionnaire", - "security.soc2_report", - "security.iso27001_certificate", - "security.private_link", - "security.enforce_mfa", - "log.retention_days", - "custom_domain", - "vanity_subdomain", - "ipv4", - "pitr.available_variants", - "log_drains", - "audit_log_drains", - "branching_limit", - "branching_persistent", - "auth.mfa_phone", - "auth.mfa_web_authn", - "auth.mfa_enhanced_security", - "auth.hooks", - "auth.platform.sso", - "auth.custom_jwt_template", - "auth.saml_2", - "auth.user_sessions", - "auth.leaked_password_protection", - "auth.advanced_auth_settings", - "auth.performance_settings", - "auth.password_hibp", - "auth.custom_oauth.max_providers", - "backup.retention_days", - "backup.restore_to_new_project", - "backup.schedule", - "function.max_count", - "function.size_limit_mb", - "realtime.max_concurrent_users", - "realtime.max_events_per_second", - "realtime.max_joins_per_second", - "realtime.max_channels_per_client", - "realtime.max_bytes_per_second", - "realtime.max_presence_events_per_second", - "realtime.max_payload_size_in_kb", - "project_scoped_roles", - "security.member_roles", - "project_pausing", - "project_cloning", - "project_restore_after_expiry", - "assistant.advance_model", - "integrations.github_connections", - "integrations.github_push_webhooks_limit", - "dedicated_pooler", - "observability.dashboard_advanced_metrics", - "api.members.invitations", - "api.members.roles", + key: Schema.Union([ + Schema.Literals([ + "instances.compute_update_available_sizes", + "instances.read_replicas", + "instances.disk_modifications", + "instances.high_availability", + "instances.orioledb", + "replication.etl", + "storage.max_file_size", + "storage.max_file_size.configurable", + "storage.image_transformations", + "storage.vector_buckets", + "storage.iceberg_catalog", + "storage.purge_cache", + "security.audit_logs_days", + "security.questionnaire", + "security.soc2_report", + "security.iso27001_certificate", + "security.private_link", + "security.enforce_mfa", + "log.retention_days", + "custom_domain", + "vanity_subdomain", + "ipv4", + "pitr.available_variants", + "log_drains", + "audit_log_drains", + "branching_limit", + "branching_persistent", + "auth.mfa_phone", + "auth.mfa_web_authn", + "auth.mfa_enhanced_security", + "auth.hooks", + "auth.platform.sso", + "auth.custom_jwt_template", + "auth.saml_2", + "auth.user_sessions", + "auth.leaked_password_protection", + "auth.advanced_auth_settings", + "auth.performance_settings", + "auth.password_hibp", + "auth.custom_oauth.max_providers", + "backup.retention_days", + "backup.restore_to_new_project", + "backup.schedule", + "function.max_count", + "function.size_limit_mb", + "realtime.max_concurrent_users", + "realtime.max_events_per_second", + "realtime.max_joins_per_second", + "realtime.max_channels_per_client", + "realtime.max_bytes_per_second", + "realtime.max_presence_events_per_second", + "realtime.max_payload_size_in_kb", + "project_scoped_roles", + "security.member_roles", + "project_pausing", + "project_cloning", + "project_restore_after_expiry", + "assistant.advance_model", + "integrations.github_connections", + "integrations.github_push_webhooks_limit", + "dedicated_pooler", + "observability.dashboard_advanced_metrics", + "api.members.invitations", + "api.members.roles", + ]), + OpenString, ]), - type: Schema.Literals(["boolean", "numeric", "set"]), + type: Schema.Union([Schema.Literals(["boolean", "numeric", "set"]), OpenString]), }), hasAccess: Schema.Boolean, - type: Schema.Literals(["boolean", "numeric", "set"]), + type: Schema.Union([Schema.Literals(["boolean", "numeric", "set"]), OpenString]), config: Schema.Union([ Schema.Struct({ enabled: Schema.Boolean }), Schema.Struct({ @@ -4145,10 +4274,16 @@ export const V1GetOrganizationProjectClaimOutput = Schema.Struct({ limit: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), }), ), - source_subscription_plan: Schema.Literals(["free", "pro", "team", "enterprise", "platform"]), - target_subscription_plan: Schema.Union([ + source_subscription_plan: Schema.Union([ Schema.Literals(["free", "pro", "team", "enterprise", "platform"]), - Schema.Null, + OpenString, + ]), + target_subscription_plan: Schema.Union([ + Schema.Union([ + Schema.Literals(["free", "pro", "team", "enterprise", "platform"]), + Schema.Null, + ]), + OpenString, ]), }), expires_at: Schema.String, @@ -4181,54 +4316,59 @@ export const V1GetPerformanceAdvisorsInput = Schema.Struct({ export const V1GetPerformanceAdvisorsOutput = Schema.Struct({ lints: Schema.Array( Schema.Struct({ - name: Schema.Literals([ - "unindexed_foreign_keys", - "auth_users_exposed", - "auth_rls_initplan", - "no_primary_key", - "unused_index", - "multiple_permissive_policies", - "policy_exists_rls_disabled", - "rls_enabled_no_policy", - "duplicate_index", - "security_definer_view", - "function_search_path_mutable", - "rls_disabled_in_public", - "extension_in_public", - "rls_references_user_metadata", - "materialized_view_in_api", - "foreign_table_in_api", - "unsupported_reg_types", - "auth_otp_long_expiry", - "auth_otp_short_length", - "ssl_not_enforced", - "log_connections_not_enabled", - "network_restrictions_not_set", - "password_requirements_min_length", - "pitr_not_enabled", - "auth_leaked_password_protection", - "auth_insufficient_mfa_options", - "auth_password_policy_missing", - "leaked_service_key", - "no_backup_admin", - "vulnerable_postgres_version", - "db_not_reachable", - "db_connection_failing", - "db_connection_limit_reached", - "instance_telemetry_lost", - "instance_db_down", - "instance_alert_firing", - "log_data_api_error_rate_high", - "log_auth_error_rate_high", - "log_storage_error_rate_high", - "log_edge_function_error_rate_high", - "project_not_active", - "advisor_check_unavailable", + name: Schema.Union([ + Schema.Literals([ + "unindexed_foreign_keys", + "auth_users_exposed", + "auth_rls_initplan", + "no_primary_key", + "unused_index", + "multiple_permissive_policies", + "policy_exists_rls_disabled", + "rls_enabled_no_policy", + "duplicate_index", + "security_definer_view", + "function_search_path_mutable", + "rls_disabled_in_public", + "extension_in_public", + "rls_references_user_metadata", + "materialized_view_in_api", + "foreign_table_in_api", + "unsupported_reg_types", + "auth_otp_long_expiry", + "auth_otp_short_length", + "ssl_not_enforced", + "log_connections_not_enabled", + "network_restrictions_not_set", + "password_requirements_min_length", + "pitr_not_enabled", + "auth_leaked_password_protection", + "auth_insufficient_mfa_options", + "auth_password_policy_missing", + "leaked_service_key", + "no_backup_admin", + "vulnerable_postgres_version", + "db_not_reachable", + "db_connection_failing", + "db_connection_limit_reached", + "instance_telemetry_lost", + "instance_db_down", + "instance_alert_firing", + "log_data_api_error_rate_high", + "log_auth_error_rate_high", + "log_storage_error_rate_high", + "log_edge_function_error_rate_high", + "project_not_active", + "advisor_check_unavailable", + ]), + OpenString, ]), title: Schema.String, - level: Schema.Literals(["ERROR", "WARN", "INFO"]), + level: Schema.Union([Schema.Literals(["ERROR", "WARN", "INFO"]), OpenString]), facing: Schema.Literal("EXTERNAL"), - categories: Schema.Array(Schema.Literals(["PERFORMANCE", "SECURITY", "HEALTH"])), + categories: Schema.Array( + Schema.Union([Schema.Literals(["PERFORMANCE", "SECURITY", "HEALTH"]), OpenString]), + ), description: Schema.String, detail: Schema.String, remediation: Schema.String, @@ -4238,16 +4378,19 @@ export const V1GetPerformanceAdvisorsOutput = Schema.Struct({ name: Schema.optionalKey(Schema.String), entity: Schema.optionalKey(Schema.String), type: Schema.optionalKey( - Schema.Literals([ - "table", - "view", - "materialized view", - "foreign table", - "auth", - "function", - "extension", - "compliance", - "health", + Schema.Union([ + Schema.Literals([ + "table", + "view", + "materialized view", + "foreign table", + "auth", + "function", + "extension", + "compliance", + "health", + ]), + OpenString, ]), ), fkey_name: Schema.optionalKey(Schema.String), @@ -4510,7 +4653,9 @@ export const V1GetPostgresConfigOutput = Schema.Struct({ }), ), ), - session_replication_role: Schema.optionalKey(Schema.Literals(["origin", "replica", "local"])), + session_replication_role: Schema.optionalKey( + Schema.Union([Schema.Literals(["origin", "replica", "local"]), OpenString]), + ), shared_buffers: Schema.optionalKey(Schema.String), statement_timeout: Schema.optionalKey( Schema.String.annotate({ description: "Default unit: ms" }).check( @@ -4554,19 +4699,21 @@ export const V1GetPostgresUpgradeEligibilityInput = Schema.Struct({ export const V1GetPostgresUpgradeEligibilityOutput = Schema.Struct({ eligible: Schema.Boolean, current_app_version: Schema.String, - current_app_version_release_channel: Schema.Literals([ - "internal", - "alpha", - "beta", - "ga", - "withdrawn", - "preview", + current_app_version_release_channel: Schema.Union([ + Schema.Literals(["internal", "alpha", "beta", "ga", "withdrawn", "preview"]), + OpenString, ]), latest_app_version: Schema.String, target_upgrade_versions: Schema.Array( Schema.Struct({ - postgres_version: Schema.Literals(["13", "14", "15", "17", "17-oriole"]), - release_channel: Schema.Literals(["internal", "alpha", "beta", "ga", "withdrawn", "preview"]), + postgres_version: Schema.Union([ + Schema.Literals(["13", "14", "15", "17", "17-oriole"]), + OpenString, + ]), + release_channel: Schema.Union([ + Schema.Literals(["internal", "alpha", "beta", "ga", "withdrawn", "preview"]), + OpenString, + ]), app_version: Schema.String, }), ), @@ -4660,31 +4807,37 @@ export const V1GetPostgresUpgradeStatusOutput = Schema.Struct({ latest_status_at: Schema.String, target_version: Schema.String, error: Schema.optionalKey( - Schema.Literals([ - "1_upgraded_instance_launch_failed", - "2_volume_detachchment_from_upgraded_instance_failed", - "3_volume_attachment_to_original_instance_failed", - "4_data_upgrade_initiation_failed", - "5_data_upgrade_completion_failed", - "6_volume_detachchment_from_original_instance_failed", - "7_volume_attachment_to_upgraded_instance_failed", - "8_upgrade_completion_failed", - "9_post_physical_backup_failed", + Schema.Union([ + Schema.Literals([ + "1_upgraded_instance_launch_failed", + "2_volume_detachchment_from_upgraded_instance_failed", + "3_volume_attachment_to_original_instance_failed", + "4_data_upgrade_initiation_failed", + "5_data_upgrade_completion_failed", + "6_volume_detachchment_from_original_instance_failed", + "7_volume_attachment_to_upgraded_instance_failed", + "8_upgrade_completion_failed", + "9_post_physical_backup_failed", + ]), + OpenString, ]), ), progress: Schema.optionalKey( - Schema.Literals([ - "0_requested", - "1_started", - "2_launched_upgraded_instance", - "3_detached_volume_from_upgraded_instance", - "4_attached_volume_to_original_instance", - "5_initiated_data_upgrade", - "6_completed_data_upgrade", - "7_detached_volume_from_original_instance", - "8_attached_volume_to_upgraded_instance", - "9_completed_upgrade", - "10_completed_post_physical_backup", + Schema.Union([ + Schema.Literals([ + "0_requested", + "1_started", + "2_launched_upgraded_instance", + "3_detached_volume_from_upgraded_instance", + "4_attached_volume_to_original_instance", + "5_initiated_data_upgrade", + "6_completed_data_upgrade", + "7_detached_volume_from_original_instance", + "8_attached_volume_to_upgraded_instance", + "9_completed_upgrade", + "10_completed_post_physical_backup", + ]), + OpenString, ]), ), status: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), @@ -4799,22 +4952,25 @@ export const V1GetProjectOutput = Schema.Struct({ name: Schema.String.annotate({ description: "Name of your project" }), region: Schema.String.annotate({ description: "Region of your project" }), created_at: Schema.String.annotate({ description: "Creation timestamp" }), - status: Schema.Literals([ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING", + status: Schema.Union([ + Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]), + OpenString, ]), database: Schema.Struct({ host: Schema.String.annotate({ description: "Database host" }), @@ -4852,7 +5008,10 @@ export const V1GetProjectApiKeyOutput = Schema.Struct({ api_key: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), type: Schema.optionalKey( - Schema.Union([Schema.Literals(["legacy", "publishable", "secret"]), Schema.Null]), + Schema.Union([ + Schema.Union([Schema.Literals(["legacy", "publishable", "secret"]), Schema.Null]), + OpenString, + ]), ), prefix: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), name: Schema.String, @@ -5106,7 +5265,9 @@ export const V1GetProjectPgbouncerConfigOutput = Schema.Struct({ }), ), ), - pool_mode: Schema.optionalKey(Schema.Literals(["transaction", "session", "statement"])), + pool_mode: Schema.optionalKey( + Schema.Union([Schema.Literals(["transaction", "session", "statement"]), OpenString]), + ), connection_string: Schema.optionalKey(Schema.String), server_idle_timeout: Schema.optionalKey( Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) @@ -5197,8 +5358,11 @@ export const V1GetProjectSigningKeyOutput = Schema.Struct({ "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", }), ), - algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), - status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + algorithm: Schema.Union([Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), OpenString]), + status: Schema.Union([ + Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + OpenString, + ]), public_jwk: Schema.Json.annotate({ expected: "JSON value" }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), @@ -5230,8 +5394,11 @@ export const V1GetProjectSigningKeysOutput = Schema.Struct({ "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", }), ), - algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), - status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + algorithm: Schema.Union([Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), OpenString]), + status: Schema.Union([ + Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + OpenString, + ]), public_jwk: Schema.Json.annotate({ expected: "JSON value" }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), @@ -5591,7 +5758,10 @@ export const V1GetRestorePointInput = Schema.Struct({ }); export const V1GetRestorePointOutput = Schema.Struct({ name: Schema.String, - status: Schema.Literals(["AVAILABLE", "PENDING", "REMOVED", "FAILED"]), + status: Schema.Union([ + Schema.Literals(["AVAILABLE", "PENDING", "REMOVED", "FAILED"]), + OpenString, + ]), completed_on: Schema.Union([Schema.String.annotate({ format: "date-time" }), Schema.Null]), }); export const V1GetSecurityAdvisorsInput = Schema.Struct({ @@ -5611,54 +5781,59 @@ export const V1GetSecurityAdvisorsInput = Schema.Struct({ export const V1GetSecurityAdvisorsOutput = Schema.Struct({ lints: Schema.Array( Schema.Struct({ - name: Schema.Literals([ - "unindexed_foreign_keys", - "auth_users_exposed", - "auth_rls_initplan", - "no_primary_key", - "unused_index", - "multiple_permissive_policies", - "policy_exists_rls_disabled", - "rls_enabled_no_policy", - "duplicate_index", - "security_definer_view", - "function_search_path_mutable", - "rls_disabled_in_public", - "extension_in_public", - "rls_references_user_metadata", - "materialized_view_in_api", - "foreign_table_in_api", - "unsupported_reg_types", - "auth_otp_long_expiry", - "auth_otp_short_length", - "ssl_not_enforced", - "log_connections_not_enabled", - "network_restrictions_not_set", - "password_requirements_min_length", - "pitr_not_enabled", - "auth_leaked_password_protection", - "auth_insufficient_mfa_options", - "auth_password_policy_missing", - "leaked_service_key", - "no_backup_admin", - "vulnerable_postgres_version", - "db_not_reachable", - "db_connection_failing", - "db_connection_limit_reached", - "instance_telemetry_lost", - "instance_db_down", - "instance_alert_firing", - "log_data_api_error_rate_high", - "log_auth_error_rate_high", - "log_storage_error_rate_high", - "log_edge_function_error_rate_high", - "project_not_active", - "advisor_check_unavailable", + name: Schema.Union([ + Schema.Literals([ + "unindexed_foreign_keys", + "auth_users_exposed", + "auth_rls_initplan", + "no_primary_key", + "unused_index", + "multiple_permissive_policies", + "policy_exists_rls_disabled", + "rls_enabled_no_policy", + "duplicate_index", + "security_definer_view", + "function_search_path_mutable", + "rls_disabled_in_public", + "extension_in_public", + "rls_references_user_metadata", + "materialized_view_in_api", + "foreign_table_in_api", + "unsupported_reg_types", + "auth_otp_long_expiry", + "auth_otp_short_length", + "ssl_not_enforced", + "log_connections_not_enabled", + "network_restrictions_not_set", + "password_requirements_min_length", + "pitr_not_enabled", + "auth_leaked_password_protection", + "auth_insufficient_mfa_options", + "auth_password_policy_missing", + "leaked_service_key", + "no_backup_admin", + "vulnerable_postgres_version", + "db_not_reachable", + "db_connection_failing", + "db_connection_limit_reached", + "instance_telemetry_lost", + "instance_db_down", + "instance_alert_firing", + "log_data_api_error_rate_high", + "log_auth_error_rate_high", + "log_storage_error_rate_high", + "log_edge_function_error_rate_high", + "project_not_active", + "advisor_check_unavailable", + ]), + OpenString, ]), title: Schema.String, - level: Schema.Literals(["ERROR", "WARN", "INFO"]), + level: Schema.Union([Schema.Literals(["ERROR", "WARN", "INFO"]), OpenString]), facing: Schema.Literal("EXTERNAL"), - categories: Schema.Array(Schema.Literals(["PERFORMANCE", "SECURITY", "HEALTH"])), + categories: Schema.Array( + Schema.Union([Schema.Literals(["PERFORMANCE", "SECURITY", "HEALTH"]), OpenString]), + ), description: Schema.String, detail: Schema.String, remediation: Schema.String, @@ -5668,16 +5843,19 @@ export const V1GetSecurityAdvisorsOutput = Schema.Struct({ name: Schema.optionalKey(Schema.String), entity: Schema.optionalKey(Schema.String), type: Schema.optionalKey( - Schema.Literals([ - "table", - "view", - "materialized view", - "foreign table", - "auth", - "function", - "extension", - "compliance", - "health", + Schema.Union([ + Schema.Literals([ + "table", + "view", + "materialized view", + "foreign table", + "auth", + "function", + "extension", + "compliance", + "health", + ]), + OpenString, ]), ), fkey_name: Schema.optionalKey(Schema.String), @@ -5852,7 +6030,9 @@ export const V1GetStorageConfigOutput = Schema.Struct({ iceberg_catalog: Schema.Boolean, object_versioning: Schema.Boolean, }), - external: Schema.Struct({ upstreamTarget: Schema.Literals(["main", "canary"]) }), + external: Schema.Struct({ + upstreamTarget: Schema.Union([Schema.Literals(["main", "canary"]), OpenString]), + }), migrationVersion: Schema.Union([Schema.String, Schema.Null]), }); export const V1GetVanitySubdomainConfigInput = Schema.Struct({ @@ -5869,7 +6049,7 @@ export const V1GetVanitySubdomainConfigInput = Schema.Struct({ ), }); export const V1GetVanitySubdomainConfigOutput = Schema.Struct({ - status: Schema.Literals(["not-used", "custom-domain-used", "active"]), + status: Schema.Union([Schema.Literals(["not-used", "custom-domain-used", "active"]), OpenString]), custom_domain: Schema.optionalKey( Schema.String.check( Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), @@ -6053,23 +6233,21 @@ export const V1ListActionRunsOutput = Schema.Array( branch_id: Schema.String, run_steps: Schema.Array( Schema.Struct({ - name: Schema.Literals([ - "clone", - "pull", - "health", - "configure", - "migrate", - "seed", - "deploy", + name: Schema.Union([ + Schema.Literals(["clone", "pull", "health", "configure", "migrate", "seed", "deploy"]), + OpenString, ]), - status: Schema.Literals([ - "CREATED", - "DEAD", - "EXITED", - "PAUSED", - "REMOVING", - "RESTARTING", - "RUNNING", + status: Schema.Union([ + Schema.Literals([ + "CREATED", + "DEAD", + "EXITED", + "PAUSED", + "REMOVING", + "RESTARTING", + "RUNNING", + ]), + OpenString, ]), created_at: Schema.String, updated_at: Schema.String, @@ -6116,13 +6294,9 @@ export const V1ListAllBackupsOutput = Schema.Struct({ }), ), is_physical_backup: Schema.Boolean, - status: Schema.Literals([ - "COMPLETED", - "FAILED", - "PENDING", - "REMOVED", - "ARCHIVED", - "CANCELLED", + status: Schema.Union([ + Schema.Literals(["COMPLETED", "FAILED", "PENDING", "REMOVED", "ARCHIVED", "CANCELLED"]), + OpenString, ]), inserted_at: Schema.String, }), @@ -6280,21 +6454,27 @@ export const V1ListAllSnippetsOutput = Schema.Struct({ inserted_at: Schema.String, updated_at: Schema.String, type: Schema.Literal("sql"), - visibility: Schema.Literals(["user", "project", "org", "public"]), + visibility: Schema.Union([Schema.Literals(["user", "project", "org", "public"]), OpenString]), name: Schema.String, description: Schema.Union([Schema.String, Schema.Null]), project: Schema.Struct({ id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), name: Schema.String, }), - owner: Schema.Struct({ - id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), - username: Schema.String, - }), - updated_by: Schema.Struct({ - id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), - username: Schema.String, - }), + owner: Schema.Union([ + Schema.Struct({ + id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + username: Schema.String, + }), + Schema.Null, + ]), + updated_by: Schema.Union([ + Schema.Struct({ + id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + username: Schema.String, + }), + Schema.Null, + ]), favorite: Schema.Boolean, }), ), @@ -6338,11 +6518,14 @@ export const V1ListAllSsoProviderOutput = Schema.Struct({ }), ), name_id_format: Schema.optionalKey( - Schema.Literals([ - "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + Schema.Union([ + Schema.Literals([ + "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + ]), + OpenString, ]), ), }), @@ -6378,8 +6561,14 @@ export const V1ListAvailableRestoreVersionsOutput = Schema.Struct({ available_versions: Schema.Array( Schema.Struct({ version: Schema.String, - release_channel: Schema.Literals(["internal", "alpha", "beta", "ga", "withdrawn", "preview"]), - postgres_engine: Schema.Literals(["13", "14", "15", "17", "17-oriole"]), + release_channel: Schema.Union([ + Schema.Literals(["internal", "alpha", "beta", "ga", "withdrawn", "preview"]), + OpenString, + ]), + postgres_engine: Schema.Union([ + Schema.Literals(["13", "14", "15", "17", "17-oriole"]), + OpenString, + ]), }), ), }); @@ -6568,40 +6757,46 @@ export const V1ListProjectAddonsInput = Schema.Struct({ export const V1ListProjectAddonsOutput = Schema.Struct({ selected_addons: Schema.Array( Schema.Struct({ - type: Schema.Literals([ - "custom_domain", - "compute_instance", - "pitr", - "ipv4", - "auth_mfa_phone", - "auth_mfa_web_authn", - "log_drain", - "etl_pipeline", - ]), - variant: Schema.Struct({ - id: Schema.Union([ - Schema.Literals([ - "ci_micro", - "ci_small", - "ci_medium", - "ci_large", - "ci_xlarge", - "ci_2xlarge", - "ci_4xlarge", - "ci_8xlarge", - "ci_12xlarge", - "ci_16xlarge", - "ci_24xlarge", - "ci_24xlarge_optimized_cpu", - "ci_24xlarge_optimized_memory", - "ci_24xlarge_high_memory", - "ci_48xlarge", - "ci_48xlarge_optimized_cpu", - "ci_48xlarge_optimized_memory", - "ci_48xlarge_high_memory", + type: Schema.Union([ + Schema.Literals([ + "custom_domain", + "compute_instance", + "pitr", + "ipv4", + "auth_mfa_phone", + "auth_mfa_web_authn", + "log_drain", + "etl_pipeline", + ]), + OpenString, + ]), + variant: Schema.Struct({ + id: Schema.Union([ + Schema.Union([ + Schema.Literals([ + "ci_micro", + "ci_small", + "ci_medium", + "ci_large", + "ci_xlarge", + "ci_2xlarge", + "ci_4xlarge", + "ci_8xlarge", + "ci_12xlarge", + "ci_16xlarge", + "ci_24xlarge", + "ci_24xlarge_optimized_cpu", + "ci_24xlarge_optimized_memory", + "ci_24xlarge_high_memory", + "ci_48xlarge", + "ci_48xlarge_optimized_cpu", + "ci_48xlarge_optimized_memory", + "ci_48xlarge_high_memory", + ]), + OpenString, ]), Schema.Literal("cd_default"), - Schema.Literals(["pitr_7", "pitr_14", "pitr_28"]), + Schema.Union([Schema.Literals(["pitr_7", "pitr_14", "pitr_28"]), OpenString]), Schema.Literal("ipv4_default"), Schema.Literal("auth_mfa_phone_default"), Schema.Literal("auth_mfa_web_authn_default"), @@ -6611,8 +6806,8 @@ export const V1ListProjectAddonsOutput = Schema.Struct({ name: Schema.String, price: Schema.Struct({ description: Schema.String, - type: Schema.Literals(["fixed", "usage"]), - interval: Schema.Literals(["monthly", "hourly"]), + type: Schema.Union([Schema.Literals(["fixed", "usage"]), OpenString]), + interval: Schema.Union([Schema.Literals(["monthly", "hourly"]), OpenString]), amount: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), }), meta: Schema.optionalKey(JsonValue_Output), @@ -6621,42 +6816,48 @@ export const V1ListProjectAddonsOutput = Schema.Struct({ ), available_addons: Schema.Array( Schema.Struct({ - type: Schema.Literals([ - "custom_domain", - "compute_instance", - "pitr", - "ipv4", - "auth_mfa_phone", - "auth_mfa_web_authn", - "log_drain", - "etl_pipeline", + type: Schema.Union([ + Schema.Literals([ + "custom_domain", + "compute_instance", + "pitr", + "ipv4", + "auth_mfa_phone", + "auth_mfa_web_authn", + "log_drain", + "etl_pipeline", + ]), + OpenString, ]), name: Schema.String, variants: Schema.Array( Schema.Struct({ id: Schema.Union([ - Schema.Literals([ - "ci_micro", - "ci_small", - "ci_medium", - "ci_large", - "ci_xlarge", - "ci_2xlarge", - "ci_4xlarge", - "ci_8xlarge", - "ci_12xlarge", - "ci_16xlarge", - "ci_24xlarge", - "ci_24xlarge_optimized_cpu", - "ci_24xlarge_optimized_memory", - "ci_24xlarge_high_memory", - "ci_48xlarge", - "ci_48xlarge_optimized_cpu", - "ci_48xlarge_optimized_memory", - "ci_48xlarge_high_memory", + Schema.Union([ + Schema.Literals([ + "ci_micro", + "ci_small", + "ci_medium", + "ci_large", + "ci_xlarge", + "ci_2xlarge", + "ci_4xlarge", + "ci_8xlarge", + "ci_12xlarge", + "ci_16xlarge", + "ci_24xlarge", + "ci_24xlarge_optimized_cpu", + "ci_24xlarge_optimized_memory", + "ci_24xlarge_high_memory", + "ci_48xlarge", + "ci_48xlarge_optimized_cpu", + "ci_48xlarge_optimized_memory", + "ci_48xlarge_high_memory", + ]), + OpenString, ]), Schema.Literal("cd_default"), - Schema.Literals(["pitr_7", "pitr_14", "pitr_28"]), + Schema.Union([Schema.Literals(["pitr_7", "pitr_14", "pitr_28"]), OpenString]), Schema.Literal("ipv4_default"), Schema.Literal("auth_mfa_phone_default"), Schema.Literal("auth_mfa_web_authn_default"), @@ -6666,8 +6867,8 @@ export const V1ListProjectAddonsOutput = Schema.Struct({ name: Schema.String, price: Schema.Struct({ description: Schema.String, - type: Schema.Literals(["fixed", "usage"]), - interval: Schema.Literals(["monthly", "hourly"]), + type: Schema.Union([Schema.Literals(["fixed", "usage"]), OpenString]), + interval: Schema.Union([Schema.Literals(["monthly", "hourly"]), OpenString]), amount: Schema.Number.check( Schema.isFinite().annotate({ expected: "a finite number" }), ), @@ -6861,10 +7062,15 @@ export const V1PatchNetworkRestrictionsInput = Schema.Struct({ ), }); export const V1PatchNetworkRestrictionsOutput = Schema.Struct({ - entitlement: Schema.Literals(["disallowed", "allowed"]), + entitlement: Schema.Union([Schema.Literals(["disallowed", "allowed"]), OpenString]), config: Schema.Struct({ dbAllowedCidrs: Schema.optionalKey( - Schema.Array(Schema.Struct({ address: Schema.String, type: Schema.Literals(["v4", "v6"]) })), + Schema.Array( + Schema.Struct({ + address: Schema.String, + type: Schema.Union([Schema.Literals(["v4", "v6"]), OpenString]), + }), + ), ), }).annotate({ description: @@ -6874,7 +7080,10 @@ export const V1PatchNetworkRestrictionsOutput = Schema.Struct({ Schema.Struct({ dbAllowedCidrs: Schema.optionalKey( Schema.Array( - Schema.Struct({ address: Schema.String, type: Schema.Literals(["v4", "v6"]) }), + Schema.Struct({ + address: Schema.String, + type: Schema.Union([Schema.Literals(["v4", "v6"]), OpenString]), + }), ), ), }).annotate({ @@ -6884,7 +7093,7 @@ export const V1PatchNetworkRestrictionsOutput = Schema.Struct({ ), updated_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), applied_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), - status: Schema.Literals(["stored", "applied"]), + status: Schema.Union([Schema.Literals(["stored", "applied"]), OpenString]), }); export const V1PauseAProjectInput = Schema.Struct({ ref: Schema.String.check( @@ -7036,8 +7245,11 @@ export const V1RemoveProjectSigningKeyOutput = Schema.Struct({ "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", }), ), - algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), - status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + algorithm: Schema.Union([Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), OpenString]), + status: Schema.Union([ + Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + OpenString, + ]), public_jwk: Schema.Json.annotate({ expected: "JSON value" }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), @@ -7394,16 +7606,19 @@ export const V1UpdateABranchConfigOutput = Schema.Struct({ }).check(Schema.isFinite().annotate({ expected: "a finite number" })), ), persistent: Schema.Boolean, - status: Schema.Literals([ - "CREATING_PROJECT", - "RUNNING_MIGRATIONS", - "MIGRATIONS_PASSED", - "MIGRATIONS_FAILED", - "FUNCTIONS_DEPLOYED", - "FUNCTIONS_FAILED", - ]).annotate({ - description: "This field is deprecated. List action runs to get branch status instead.", - }), + status: Schema.Union([ + Schema.Literals([ + "CREATING_PROJECT", + "RUNNING_MIGRATIONS", + "MIGRATIONS_PASSED", + "MIGRATIONS_FAILED", + "FUNCTIONS_DEPLOYED", + "FUNCTIONS_FAILED", + ]).annotate({ + description: "This field is deprecated. List action runs to get branch status instead.", + }), + OpenString, + ]), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), review_requested_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), @@ -7411,22 +7626,25 @@ export const V1UpdateABranchConfigOutput = Schema.Struct({ notify_url: Schema.optionalKey(Schema.String.annotate({ format: "uri" })), deletion_scheduled_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), preview_project_status: Schema.optionalKey( - Schema.Literals([ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING", + Schema.Union([ + Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]), + OpenString, ]), ), }); @@ -7466,7 +7684,7 @@ export const V1UpdateAFunctionOutput = Schema.Struct({ id: Schema.String, slug: Schema.String, name: Schema.String, - status: Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), + status: Schema.Union([Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), OpenString]), version: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -7505,7 +7723,7 @@ export const V1UpdateAFunctionOutput = Schema.Struct({ verify_jwt: Schema.optionalKey(Schema.Boolean), import_map: Schema.optionalKey(Schema.Boolean), entrypoint_path: Schema.optionalKey(Schema.String), - import_map_path: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + import_map_path: Schema.optionalKey(Schema.String), ezbr_sha256: Schema.optionalKey(Schema.String), }); export const V1UpdateAProjectInput = Schema.Struct({ @@ -7612,11 +7830,14 @@ export const V1UpdateASsoProviderOutput = Schema.Struct({ }), ), name_id_format: Schema.optionalKey( - Schema.Literals([ - "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + Schema.Union([ + Schema.Literals([ + "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + ]), + OpenString, ]), ), }), @@ -8452,7 +8673,10 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ ), Schema.Null, ]), - db_max_pool_size_unit: Schema.Union([Schema.Literals(["connections", "percent"]), Schema.Null]), + db_max_pool_size_unit: Schema.Union([ + Schema.Union([Schema.Literals(["connections", "percent"]), Schema.Null]), + OpenString, + ]), disable_signup: Schema.Union([Schema.Boolean, Schema.Null]), external_anonymous_users_enabled: Schema.Union([Schema.Boolean, Schema.Null]), external_apple_additional_client_ids: Schema.Union([Schema.String, Schema.Null]), @@ -8708,7 +8932,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), nimbus_oauth_client_id: Schema.Union([Schema.String, Schema.Null]), - nimbus_oauth_email_optional: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), + nimbus_oauth_email_optional: Schema.Union([Schema.Boolean, Schema.Null]), nimbus_oauth_client_secret: Schema.Union([Schema.String, Schema.Null]), password_hibp_enabled: Schema.Union([Schema.Boolean, Schema.Null]), password_min_length: Schema.Union([ @@ -8726,13 +8950,16 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), password_required_characters: Schema.Union([ - Schema.Literals([ - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", - "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", - "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\\\:\"|<>?,./`~", - "", + Schema.Union([ + Schema.Literals([ + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", + "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", + "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\\\:\"|<>?,./`~", + "", + ]), + Schema.Null, ]), - Schema.Null, + OpenString, ]), rate_limit_anonymous_users: Schema.Union([ Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) @@ -8839,8 +9066,8 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ security_sb_forwarded_for_enabled: Schema.Union([Schema.Boolean, Schema.Null]), security_captcha_enabled: Schema.Union([Schema.Boolean, Schema.Null]), security_captcha_provider: Schema.Union([ - Schema.Literals(["turnstile", "hcaptcha"]), - Schema.Null, + Schema.Union([Schema.Literals(["turnstile", "hcaptcha"]), Schema.Null]), + OpenString, ]), security_captcha_secret: Schema.Union([Schema.String, Schema.Null]), security_manual_linking_enabled: Schema.Union([Schema.Boolean, Schema.Null]), @@ -8914,8 +9141,11 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ }), ), sms_provider: Schema.Union([ - Schema.Literals(["messagebird", "textlocal", "twilio", "twilio_verify", "vonage"]), - Schema.Null, + Schema.Union([ + Schema.Literals(["messagebird", "textlocal", "twilio", "twilio_verify", "vonage"]), + Schema.Null, + ]), + OpenString, ]), sms_template: Schema.Union([Schema.String, Schema.Null]), sms_test_otp: Schema.Union([Schema.String, Schema.Null]), @@ -9063,12 +9293,15 @@ export const V1UpdateHostnameConfigInput = Schema.Struct({ }); export const V1UpdateHostnameConfigOutput = Schema.Struct({ status: Schema.optionalKey( - Schema.Literals([ - "1_not_started", - "2_initiated", - "3_challenge_verified", - "4_origin_setup_completed", - "5_services_reconfigured", + Schema.Union([ + Schema.Literals([ + "1_not_started", + "2_initiated", + "3_challenge_verified", + "4_origin_setup_completed", + "5_services_reconfigured", + ]), + OpenString, ]), ), custom_hostname: Schema.optionalKey(Schema.String), @@ -9262,16 +9495,19 @@ export const V1UpdateJitAccessConfigInput = Schema.Struct({ export const V1UpdateJitAccessConfigOutput = Schema.Union( [ Schema.Struct({ - state: Schema.Literals(["enabled", "disabled"]), + state: Schema.Union([Schema.Literals(["enabled", "disabled"]), OpenString]), appliedSuccessfully: Schema.optionalKey(Schema.Boolean), }), Schema.Struct({ state: Schema.Literal("unavailable"), - unavailableReason: Schema.Literals([ - "platform_unsupported", - "postgres_upgrade_required", - "ssl_enforcement_required", - "temporarily_unavailable", + unavailableReason: Schema.Union([ + Schema.Literals([ + "platform_unsupported", + "postgres_upgrade_required", + "ssl_enforcement_required", + "temporarily_unavailable", + ]), + OpenString, ]), }), ], @@ -9293,7 +9529,7 @@ export const V1UpdateNetworkRestrictionsInput = Schema.Struct({ dbAllowedCidrsV6: Schema.optionalKey(Schema.Array(Schema.String)), }); export const V1UpdateNetworkRestrictionsOutput = Schema.Struct({ - entitlement: Schema.Literals(["disallowed", "allowed"]), + entitlement: Schema.Union([Schema.Literals(["disallowed", "allowed"]), OpenString]), config: Schema.Struct({ dbAllowedCidrs: Schema.optionalKey(Schema.Array(Schema.String)), dbAllowedCidrsV6: Schema.optionalKey(Schema.Array(Schema.String)), @@ -9310,7 +9546,7 @@ export const V1UpdateNetworkRestrictionsOutput = Schema.Struct({ "Populated when a new config has been received, but not registered as successfully applied to a project.", }), ), - status: Schema.Literals(["stored", "applied"]), + status: Schema.Union([Schema.Literals(["stored", "applied"]), OpenString]), updated_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), applied_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), }); @@ -9830,7 +10066,9 @@ export const V1UpdatePostgresConfigOutput = Schema.Struct({ }), ), ), - session_replication_role: Schema.optionalKey(Schema.Literals(["origin", "replica", "local"])), + session_replication_role: Schema.optionalKey( + Schema.Union([Schema.Literals(["origin", "replica", "local"]), OpenString]), + ), shared_buffers: Schema.optionalKey(Schema.String), statement_timeout: Schema.optionalKey( Schema.String.annotate({ description: "Default unit: ms" }).check( @@ -10008,7 +10246,10 @@ export const V1UpdateProjectApiKeyOutput = Schema.Struct({ api_key: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), type: Schema.optionalKey( - Schema.Union([Schema.Literals(["legacy", "publishable", "secret"]), Schema.Null]), + Schema.Union([ + Schema.Union([Schema.Literals(["legacy", "publishable", "secret"]), Schema.Null]), + OpenString, + ]), ), prefix: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), name: Schema.String, @@ -10079,8 +10320,11 @@ export const V1UpdateProjectSigningKeyOutput = Schema.Struct({ "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", }), ), - algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), - status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + algorithm: Schema.Union([Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), OpenString]), + status: Schema.Union([ + Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + OpenString, + ]), public_jwk: Schema.Json.annotate({ expected: "JSON value" }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), @@ -10412,12 +10656,15 @@ export const V1VerifyDnsConfigInput = Schema.Struct({ }); export const V1VerifyDnsConfigOutput = Schema.Struct({ status: Schema.optionalKey( - Schema.Literals([ - "1_not_started", - "2_initiated", - "3_challenge_verified", - "4_origin_setup_completed", - "5_services_reconfigured", + Schema.Union([ + Schema.Literals([ + "1_not_started", + "2_initiated", + "3_challenge_verified", + "4_origin_setup_completed", + "5_services_reconfigured", + ]), + OpenString, ]), ), custom_hostname: Schema.optionalKey(Schema.String), @@ -10507,10 +10754,13 @@ export const V2AssignOrganizationMemberRoleOutput = Schema.Struct({ name: Schema.String.annotate({ description: "Role name. For project-scoped assignments this is the base role name.", }), - scope: Schema.Literals(["organization", "project"]).annotate({ - description: - "Whether this role applies org-wide or is scoped to specific projects for the user.", - }), + scope: Schema.Union([ + Schema.Literals(["organization", "project"]).annotate({ + description: + "Whether this role applies org-wide or is scoped to specific projects for the user.", + }), + OpenString, + ]), projects: Schema.Array(Schema.Struct({ ref: Schema.String, name: Schema.String })).annotate({ description: "Project refs this role is scoped to. Empty array for org-level roles.", }), @@ -10824,7 +11074,7 @@ export const V2CreateLogDrainOutput = Schema.Struct({ config: Schema.Union([ Schema.Struct({ url: Schema.optionalKey(Schema.String), - http: Schema.optionalKey(Schema.Literals(["http1", "http2"])), + http: Schema.optionalKey(Schema.Union([Schema.Literals(["http1", "http2"]), OpenString])), gzip: Schema.optionalKey(Schema.Boolean), headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }).annotate({ title: "webhook" }), @@ -10897,19 +11147,22 @@ export const V2CreateLogDrainOutput = Schema.Struct({ headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }).annotate({ title: "otlp" }), ]), - backend_type: Schema.Literals([ - "postgres", - "bigquery", - "clickhouse", - "webhook", - "datadog", - "loki", - "sentry", - "s3", - "axiom", - "last9", - "otlp", - "syslog", + backend_type: Schema.Union([ + Schema.Literals([ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog", + ]), + OpenString, ]), }), }), @@ -11182,10 +11435,12 @@ export const V2CreateNotebookOutput = Schema.Struct({ }), ), title: Schema.optionalKey(Schema.String), - view: Schema.optionalKey(Schema.Literals(["table", "chart"])), + view: Schema.optionalKey( + Schema.Union([Schema.Literals(["table", "chart"]), OpenString]), + ), chart: Schema.optionalKey( Schema.Struct({ - type: Schema.Literals(["bar", "line"]), + type: Schema.Union([Schema.Literals(["bar", "line"]), OpenString]), x_column: Schema.String.annotate({ description: "Result column used for the x axis.", }), @@ -11196,7 +11451,7 @@ export const V2CreateNotebookOutput = Schema.Struct({ }), }), ), - scale: Schema.Literals(["linear", "log"]), + scale: Schema.Union([Schema.Literals(["linear", "log"]), OpenString]), cumulative: Schema.Boolean, show_labels: Schema.Boolean, }).annotate({ @@ -11219,7 +11474,10 @@ export const V2CreateNotebookOutput = Schema.Struct({ }), Schema.Struct({ type: Schema.Literal("relative"), - unit: Schema.Literals(["minute", "hour", "day", "week", "month", "year"]), + unit: Schema.Union([ + Schema.Literals(["minute", "hour", "day", "week", "month", "year"]), + OpenString, + ]), amount: Schema.Number.check( Schema.isInt().annotate({ expected: "an integer" }), ) @@ -11236,10 +11494,12 @@ export const V2CreateNotebookOutput = Schema.Struct({ { mode: "oneOf" }, ), title: Schema.optionalKey(Schema.String), - view: Schema.optionalKey(Schema.Literals(["table", "chart"])), + view: Schema.optionalKey( + Schema.Union([Schema.Literals(["table", "chart"]), OpenString]), + ), chart: Schema.optionalKey( Schema.Struct({ - type: Schema.Literals(["bar", "line"]), + type: Schema.Union([Schema.Literals(["bar", "line"]), OpenString]), x_column: Schema.String.annotate({ description: "Result column used for the x axis.", }), @@ -11250,7 +11510,7 @@ export const V2CreateNotebookOutput = Schema.Struct({ }), }), ), - scale: Schema.Literals(["linear", "log"]), + scale: Schema.Union([Schema.Literals(["linear", "log"]), OpenString]), cumulative: Schema.Boolean, show_labels: Schema.Boolean, }).annotate({ @@ -11323,47 +11583,50 @@ export const V2CreateOrganizationInvitationsOutput = Schema.Struct({ error: Schema.optionalKey( Schema.Struct({ id: Schema.optionalKey(Schema.String), - code: Schema.Literals([ - "bad_request", - "forbidden", - "internal_server_error", - "not_found", - "payment_required", - "rate_limit_exceeded", - "request_timeout", - "request_too_large", - "unauthorized", - "validation_failed", - "agentic_credential_not_found", - "branch_admin_required", - "compute_build_context_too_large", - "compute_build_in_progress", - "compute_deploy_rate_limit_exceeded", - "compute_instance_limit_exceeded", - "compute_instance_not_found", - "compute_not_enabled", - "compute_request_rejected", - "compute_unavailable", - "notebook_cell_duplicate", - "notebook_cell_unknown", - "notebook_disabled", - "notebook_not_found", - "notebook_sort_invalid", - "organization_invitation_email_delivery_failed", - "organization_invitations_partially_failed", - "organization_role_name_invalid", - "organization_role_not_entitled", - "pagination_cursor_invalid", - "project_branch_resync_unsupported", - "project_not_found", - "project_privatelink_disabled", - "project_transfer_disabled", - "delivery_not_found", - "endpoint_disabled", - "endpoint_event_type_not_subscribed", - "endpoint_not_found", - "endpoint_url_unresolvable", - "webhooks_access_disabled", + code: Schema.Union([ + Schema.Literals([ + "bad_request", + "forbidden", + "internal_server_error", + "not_found", + "payment_required", + "rate_limit_exceeded", + "request_timeout", + "request_too_large", + "unauthorized", + "validation_failed", + "agentic_credential_not_found", + "branch_admin_required", + "compute_build_context_too_large", + "compute_build_in_progress", + "compute_deploy_rate_limit_exceeded", + "compute_instance_limit_exceeded", + "compute_instance_not_found", + "compute_not_enabled", + "compute_request_rejected", + "compute_unavailable", + "notebook_cell_duplicate", + "notebook_cell_unknown", + "notebook_disabled", + "notebook_not_found", + "notebook_sort_invalid", + "organization_invitation_email_delivery_failed", + "organization_invitations_partially_failed", + "organization_role_name_invalid", + "organization_role_not_entitled", + "pagination_cursor_invalid", + "project_branch_resync_unsupported", + "project_not_found", + "project_privatelink_disabled", + "project_transfer_disabled", + "delivery_not_found", + "endpoint_disabled", + "endpoint_event_type_not_subscribed", + "endpoint_not_found", + "endpoint_url_unresolvable", + "webhooks_access_disabled", + ]), + OpenString, ]), message: Schema.String, description: Schema.optionalKey(Schema.String), @@ -11523,17 +11786,20 @@ export const V2CreatePrivateLinkAssociationOutput = Schema.Struct({ account_name: Schema.optionalKey( Schema.String.annotate({ description: "Human-readable name for the AWS account." }), ), - status: Schema.Literals([ - "CREATING", - "READY", - "ASSOCIATION_REQUEST_EXPIRED", - "ASSOCIATION_ACCEPTED", - "CREATION_FAILED", - "DELETING", - ]).annotate({ - description: - "\n - `CREATING`: The PrivateLink resources and the Association are in the process of being created. The PrivateLink Share cannot be accepted yet.\n - `READY`: The PrivateLink resources have been created and the PrivateLink Share can be accepted for the duration of 12h after sharing. See `shared_at`.\n - `ASSOCIATION_REQUEST_EXPIRED`: The PrivateLink Share has not been accepted within the 12h time limit. This association can now be deleted.\n - `ASSOCIATION_ACCEPTED`: The PrivateLink Share was successfully accepted.\n - `CREATION_FAILED`: The PrivateLink resources failed to create. This likely means something went wrong on Supabase's and and support should be contacted.\n - `DELETING`: The PrivateLink resources and the Association are in the process of being deleted. The PrivateLink Share cannot be accepted yet.\n", - }), + status: Schema.Union([ + Schema.Literals([ + "CREATING", + "READY", + "ASSOCIATION_REQUEST_EXPIRED", + "ASSOCIATION_ACCEPTED", + "CREATION_FAILED", + "DELETING", + ]).annotate({ + description: + "\n - `CREATING`: The PrivateLink resources and the Association are in the process of being created. The PrivateLink Share cannot be accepted yet.\n - `READY`: The PrivateLink resources have been created and the PrivateLink Share can be accepted for the duration of 12h after sharing. See `shared_at`.\n - `ASSOCIATION_REQUEST_EXPIRED`: The PrivateLink Share has not been accepted within the 12h time limit. This association can now be deleted.\n - `ASSOCIATION_ACCEPTED`: The PrivateLink Share was successfully accepted.\n - `CREATION_FAILED`: The PrivateLink resources failed to create. This likely means something went wrong on Supabase's and and support should be contacted.\n - `DELETING`: The PrivateLink resources and the Association are in the process of being deleted. The PrivateLink Share cannot be accepted yet.\n", + }), + OpenString, + ]), shared_at: Schema.Union([ Schema.String.annotate({ description: @@ -11542,10 +11808,13 @@ export const V2CreatePrivateLinkAssociationOutput = Schema.Struct({ }), Schema.Null, ]), - database_type: Schema.Literals(["PRIMARY", "READ_REPLICA"]).annotate({ - description: - "Whether this PrivateLink share targets the primary database or a read replica.", - }), + database_type: Schema.Union([ + Schema.Literals(["PRIMARY", "READ_REPLICA"]).annotate({ + description: + "Whether this PrivateLink share targets the primary database or a read replica.", + }), + OpenString, + ]), database_identifier: Schema.String.annotate({ description: "Identifier of the database this PrivateLink share targets - the project ref for the primary, or the read replica identifier.", @@ -11789,7 +12058,7 @@ export const V2DeployAComputeInstanceOutput = Schema.Struct({ }), ), }), - build_state: Schema.Literals(["building", "active", "failed"]), + build_state: Schema.Union([Schema.Literals(["building", "active", "failed"]), OpenString]), secret_generation: Schema.String, state_reason: Schema.optionalKey(Schema.String), image_version: Schema.optionalKey(Schema.String), @@ -11885,7 +12154,7 @@ export const V2GetAComputeInstanceOutput = Schema.Struct({ }), ), }), - build_state: Schema.Literals(["building", "active", "failed"]), + build_state: Schema.Union([Schema.Literals(["building", "active", "failed"]), OpenString]), secret_generation: Schema.String, state_reason: Schema.optionalKey(Schema.String), image_version: Schema.optionalKey(Schema.String), @@ -12021,10 +12290,12 @@ export const V2GetNotebookOutput = Schema.Struct({ }), ), title: Schema.optionalKey(Schema.String), - view: Schema.optionalKey(Schema.Literals(["table", "chart"])), + view: Schema.optionalKey( + Schema.Union([Schema.Literals(["table", "chart"]), OpenString]), + ), chart: Schema.optionalKey( Schema.Struct({ - type: Schema.Literals(["bar", "line"]), + type: Schema.Union([Schema.Literals(["bar", "line"]), OpenString]), x_column: Schema.String.annotate({ description: "Result column used for the x axis.", }), @@ -12035,7 +12306,7 @@ export const V2GetNotebookOutput = Schema.Struct({ }), }), ), - scale: Schema.Literals(["linear", "log"]), + scale: Schema.Union([Schema.Literals(["linear", "log"]), OpenString]), cumulative: Schema.Boolean, show_labels: Schema.Boolean, }).annotate({ @@ -12058,7 +12329,10 @@ export const V2GetNotebookOutput = Schema.Struct({ }), Schema.Struct({ type: Schema.Literal("relative"), - unit: Schema.Literals(["minute", "hour", "day", "week", "month", "year"]), + unit: Schema.Union([ + Schema.Literals(["minute", "hour", "day", "week", "month", "year"]), + OpenString, + ]), amount: Schema.Number.check( Schema.isInt().annotate({ expected: "an integer" }), ) @@ -12075,10 +12349,12 @@ export const V2GetNotebookOutput = Schema.Struct({ { mode: "oneOf" }, ), title: Schema.optionalKey(Schema.String), - view: Schema.optionalKey(Schema.Literals(["table", "chart"])), + view: Schema.optionalKey( + Schema.Union([Schema.Literals(["table", "chart"]), OpenString]), + ), chart: Schema.optionalKey( Schema.Struct({ - type: Schema.Literals(["bar", "line"]), + type: Schema.Union([Schema.Literals(["bar", "line"]), OpenString]), x_column: Schema.String.annotate({ description: "Result column used for the x axis.", }), @@ -12089,7 +12365,7 @@ export const V2GetNotebookOutput = Schema.Struct({ }), }), ), - scale: Schema.Literals(["linear", "log"]), + scale: Schema.Union([Schema.Literals(["linear", "log"]), OpenString]), cumulative: Schema.Boolean, show_labels: Schema.Boolean, }).annotate({ @@ -12143,12 +12419,18 @@ export const V2GetProjectConfigOutput = Schema.Struct({ description: "Whether the database rejects plaintext connections", }), network_restrictions: Schema.Struct({ - entitlement: Schema.Literals(["disallowed", "allowed"]), - status: Schema.Literals(["stored", "applied"]).annotate({ - description: "Whether the allowlist below is applied to the project or only stored.", - }), + entitlement: Schema.Union([Schema.Literals(["disallowed", "allowed"]), OpenString]), + status: Schema.Union([ + Schema.Literals(["stored", "applied"]).annotate({ + description: "Whether the allowlist below is applied to the project or only stored.", + }), + OpenString, + ]), allowed_cidrs: Schema.Array( - Schema.Struct({ address: Schema.String, type: Schema.Literals(["v4", "v6"]) }), + Schema.Struct({ + address: Schema.String, + type: Schema.Union([Schema.Literals(["v4", "v6"]), OpenString]), + }), ), updated_at: Schema.optionalKey(Schema.String), applied_at: Schema.optionalKey(Schema.String), @@ -12335,7 +12617,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ ), ), session_replication_role: Schema.optionalKey( - Schema.Literals(["origin", "replica", "local"]), + Schema.Union([Schema.Literals(["origin", "replica", "local"]), OpenString]), ), shared_buffers: Schema.optionalKey(Schema.String), statement_timeout: Schema.optionalKey( @@ -12405,7 +12687,10 @@ export const V2GetProjectConfigOutput = Schema.Struct({ }), }), pooler: Schema.Struct({ - pool_mode: Schema.Literals(["transaction", "session", "statement"]), + pool_mode: Schema.Union([ + Schema.Literals(["transaction", "session", "statement"]), + OpenString, + ]), ignore_startup_parameters: Schema.String, server_idle_timeout: Schema.Number.check( Schema.isInt().annotate({ expected: "an integer" }), @@ -12748,7 +13033,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ iceberg_catalog: Schema.Boolean, object_versioning: Schema.Boolean, }), - upstream_target: Schema.Literals(["main", "canary"]), + upstream_target: Schema.Union([Schema.Literals(["main", "canary"]), OpenString]), migration_version: Schema.Union([Schema.String, Schema.Null]), }).annotate({ description: @@ -12792,7 +13077,7 @@ export const V2ListAllComputeInstancesOutput = Schema.Struct({ }), ), }), - build_state: Schema.Literals(["building", "active", "failed"]), + build_state: Schema.Union([Schema.Literals(["building", "active", "failed"]), OpenString]), secret_generation: Schema.String, state_reason: Schema.optionalKey(Schema.String), image_version: Schema.optionalKey(Schema.String), @@ -12874,7 +13159,9 @@ export const V2ListLogDrainsOutput = Schema.Struct({ config: Schema.Union([ Schema.Struct({ url: Schema.optionalKey(Schema.String), - http: Schema.optionalKey(Schema.Literals(["http1", "http2"])), + http: Schema.optionalKey( + Schema.Union([Schema.Literals(["http1", "http2"]), OpenString]), + ), gzip: Schema.optionalKey(Schema.Boolean), headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }).annotate({ title: "webhook" }), @@ -12947,19 +13234,22 @@ export const V2ListLogDrainsOutput = Schema.Struct({ headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }).annotate({ title: "otlp" }), ]), - backend_type: Schema.Literals([ - "postgres", - "bigquery", - "clickhouse", - "webhook", - "datadog", - "loki", - "sentry", - "s3", - "axiom", - "last9", - "otlp", - "syslog", + backend_type: Schema.Union([ + Schema.Literals([ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog", + ]), + OpenString, ]), }), }), @@ -13336,10 +13626,13 @@ export const V2ListOrganizationMembersOutput = Schema.Struct({ name: Schema.String.annotate({ description: "Role name. For project-scoped roles this is the base role name.", }), - scope: Schema.Literals(["organization", "project"]).annotate({ - description: - "Whether this role applies org-wide or is scoped to specific projects for the user.", - }), + scope: Schema.Union([ + Schema.Literals(["organization", "project"]).annotate({ + description: + "Whether this role applies org-wide or is scoped to specific projects for the user.", + }), + OpenString, + ]), projects: Schema.Array( Schema.Struct({ ref: Schema.String, name: Schema.String }), ).annotate({ @@ -13436,23 +13729,26 @@ export const V2ListOrganizationProjectsOutput = Schema.Struct({ ), attributes: Schema.Struct({ name: Schema.String.annotate({ description: "Project name" }), - status: Schema.Literals([ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING", - ]).annotate({ description: "Project status" }), + status: Schema.Union([ + Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]).annotate({ description: "Project status" }), + OpenString, + ]), cloud_provider: Schema.String.annotate({ description: "Cloud provider hosting the project", }), @@ -13463,49 +13759,57 @@ export const V2ListOrganizationProjectsOutput = Schema.Struct({ cloud_provider: Schema.String, identifier: Schema.String, region: Schema.Union([Schema.String, Schema.Null]), - status: Schema.Literals([ - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UNKNOWN", - "INIT_READ_REPLICA", - "INIT_READ_REPLICA_FAILED", - "RESTARTING", - "RESIZING", + status: Schema.Union([ + Schema.Literals([ + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UNKNOWN", + "INIT_READ_REPLICA", + "INIT_READ_REPLICA_FAILED", + "RESTARTING", + "RESIZING", + ]), + OpenString, ]), - type: Schema.Literals(["PRIMARY", "READ_REPLICA"]), + type: Schema.Union([Schema.Literals(["PRIMARY", "READ_REPLICA"]), OpenString]), infra_compute_size: Schema.optionalKey( - Schema.Literals([ - "pico", - "nano", - "micro", - "small", - "medium", - "large", - "xlarge", - "2xlarge", - "4xlarge", - "8xlarge", - "12xlarge", - "16xlarge", - "24xlarge", - "24xlarge_optimized_memory", - "24xlarge_optimized_cpu", - "24xlarge_high_memory", - "48xlarge", - "48xlarge_optimized_memory", - "48xlarge_optimized_cpu", - "48xlarge_high_memory", + Schema.Union([ + Schema.Literals([ + "pico", + "nano", + "micro", + "small", + "medium", + "large", + "xlarge", + "2xlarge", + "4xlarge", + "8xlarge", + "12xlarge", + "16xlarge", + "24xlarge", + "24xlarge_optimized_memory", + "24xlarge_optimized_cpu", + "24xlarge_high_memory", + "48xlarge", + "48xlarge_optimized_memory", + "48xlarge_optimized_cpu", + "48xlarge_high_memory", + ]), + OpenString, ]), ), disk_volume_size_gb: Schema.optionalKey( Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), - disk_type: Schema.optionalKey(Schema.Literals(["gp3", "io2"])), + disk_type: Schema.optionalKey( + Schema.Union([Schema.Literals(["gp3", "io2"]), OpenString]), + ), disk_throughput_mbps: Schema.optionalKey( Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), @@ -13595,17 +13899,20 @@ export const V2ListPrivateLinkAssociationsOutput = Schema.Struct({ account_name: Schema.optionalKey( Schema.String.annotate({ description: "Human-readable name for the AWS account." }), ), - status: Schema.Literals([ - "CREATING", - "READY", - "ASSOCIATION_REQUEST_EXPIRED", - "ASSOCIATION_ACCEPTED", - "CREATION_FAILED", - "DELETING", - ]).annotate({ - description: - "\n - `CREATING`: The PrivateLink resources and the Association are in the process of being created. The PrivateLink Share cannot be accepted yet.\n - `READY`: The PrivateLink resources have been created and the PrivateLink Share can be accepted for the duration of 12h after sharing. See `shared_at`.\n - `ASSOCIATION_REQUEST_EXPIRED`: The PrivateLink Share has not been accepted within the 12h time limit. This association can now be deleted.\n - `ASSOCIATION_ACCEPTED`: The PrivateLink Share was successfully accepted.\n - `CREATION_FAILED`: The PrivateLink resources failed to create. This likely means something went wrong on Supabase's and and support should be contacted.\n - `DELETING`: The PrivateLink resources and the Association are in the process of being deleted. The PrivateLink Share cannot be accepted yet.\n", - }), + status: Schema.Union([ + Schema.Literals([ + "CREATING", + "READY", + "ASSOCIATION_REQUEST_EXPIRED", + "ASSOCIATION_ACCEPTED", + "CREATION_FAILED", + "DELETING", + ]).annotate({ + description: + "\n - `CREATING`: The PrivateLink resources and the Association are in the process of being created. The PrivateLink Share cannot be accepted yet.\n - `READY`: The PrivateLink resources have been created and the PrivateLink Share can be accepted for the duration of 12h after sharing. See `shared_at`.\n - `ASSOCIATION_REQUEST_EXPIRED`: The PrivateLink Share has not been accepted within the 12h time limit. This association can now be deleted.\n - `ASSOCIATION_ACCEPTED`: The PrivateLink Share was successfully accepted.\n - `CREATION_FAILED`: The PrivateLink resources failed to create. This likely means something went wrong on Supabase's and and support should be contacted.\n - `DELETING`: The PrivateLink resources and the Association are in the process of being deleted. The PrivateLink Share cannot be accepted yet.\n", + }), + OpenString, + ]), shared_at: Schema.Union([ Schema.String.annotate({ description: @@ -13614,10 +13921,13 @@ export const V2ListPrivateLinkAssociationsOutput = Schema.Struct({ }), Schema.Null, ]), - database_type: Schema.Literals(["PRIMARY", "READ_REPLICA"]).annotate({ - description: - "Whether this PrivateLink share targets the primary database or a read replica.", - }), + database_type: Schema.Union([ + Schema.Literals(["PRIMARY", "READ_REPLICA"]).annotate({ + description: + "Whether this PrivateLink share targets the primary database or a read replica.", + }), + OpenString, + ]), database_identifier: Schema.String.annotate({ description: "Identifier of the database this PrivateLink share targets - the project ref for the primary, or the read replica identifier.", @@ -13648,31 +13958,2843 @@ export const V2ListPrivateLinkAssociationsOutput = Schema.Struct({ }), ), }); -export const V2PreviewAProjectTransferInput = Schema.Struct({ - ref: Schema.String.check( - Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), - ) - .check( - Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), - ) - .check( - Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ - expected: "a string matching the RegExp ^[a-z]+$", +export const V2OrganizationsSlugWebhooksDeliveriesIdGetInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), +}); +export const V2OrganizationsSlugWebhooksDeliveriesIdGetOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("delivery").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of a delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of a delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + event_id: Schema.String.annotate({ + description: "Identifier of the event, which triggered the delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + status: Schema.Union([ + Schema.Literals(["pending", "success", "failure", "skipped"]).annotate({ + description: "Status of the delivery attempt.", + }), + OpenString, + ]), + response_code: Schema.Number.annotate({ + description: "HTTP status code of the response, `0` if unavailable.", + }) + .check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + response_headers: Schema.Union([ + Schema.Record(Schema.String, Schema.String), + Schema.Null, + ]).annotate({ description: "HTTP headers of the response, `{}` if unavailable." }), + response_body: Schema.Union([ + Schema.Union([ + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Parsed JSON representation of an HTTP body of the response, `{}` if unavailable.", + }), + Schema.String.annotate({ + description: "String representation of an HTTP body of the response.", + }), + ]), + Schema.Null, + ]), + attempt_timestamp: Schema.String.annotate({ + description: "Timestamp of the attempt.", + format: "date-time", + }), + event: Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "project.v1.paused", + "project.v1.created", + "project.v1.restored", + "project.v1.transferred", + "project.v1.removed", + "project.v1.restarted", + "project.v1.status.changed", + "project.v1.backup.started", + "project.v1.branch.created", + "project.v1.branch.updated", + "project.v1.branch.removed", + "organization.v1.member.invitation.created", + "organization.v1.member.invitation.canceled", + "organization.v1.member.added", + "organization.v1.member.removed", + "organization.v1.member.role.assigned", + "organization.v1.member.role.removed", + "organization.v1.member.role.updated", + "organization.v1.billing.plan.upgraded", + "organization.v1.billing.plan.downgraded", + "project.v1.branch.deleted", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + payload: Schema.Struct({ + organization_slug: Schema.String.annotate({ description: "Organization slug" }).check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + project_ref: Schema.Union([ + Schema.String.annotate({ description: "Project ref" }) + .check( + Schema.isMinCodePoints(20).annotate({ + expected: "a string with at least 20 code points", + }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ + expected: "a string with at most 20 code points", + }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + Schema.Null, + ]), + }).annotate({ description: "Final data sent to the consumer." }), + timestamp: Schema.String.annotate({ + description: "Timestamp of event publication.", + format: "date-time", + }), + }), + }), + }), +}); +export const V2OrganizationsSlugWebhooksDeliveriesIdRetryPostInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), +}); +export const V2OrganizationsSlugWebhooksDeliveriesIdRetryPostOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("ingress").annotate({ description: "Resource type." }), + id: Schema.String, + }), +}); +export const V2OrganizationsSlugWebhooksEndpointsDeleteInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), +}); +export const V2OrganizationsSlugWebhooksEndpointsDeleteOutput = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check( + Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), + ), + custom_headers: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), + ), +}); +export const V2OrganizationsSlugWebhooksEndpointsGetInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + "page[offset]": Schema.optionalKey( + Schema.String.check( + Schema.isPattern(new RegExp("^\\d+$", "u")).annotate({ + expected: "a string matching the RegExp ^\\d+$", + }), + ), + ), + "page[limit]": Schema.optionalKey( + Schema.String.check( + Schema.isPattern(new RegExp("^\\d+$", "u")).annotate({ + expected: "a string matching the RegExp ^\\d+$", + }), + ), + ), +}); +export const V2OrganizationsSlugWebhooksEndpointsGetOutput = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check( + Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), + ), + custom_headers: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), + ), + links: Schema.Struct({ + first: Schema.optionalKey( + Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the first page if available.", + }), + ), + prev: Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the previous page.", + }), + next: Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the next page.", + }), + last: Schema.optionalKey( + Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the last page if available.", + }), + ), + }), +}); +export const V2OrganizationsSlugWebhooksEndpointsIdDeleteInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), +}); +export const V2OrganizationsSlugWebhooksEndpointsIdDeleteOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + custom_headers: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), +}); +export const V2OrganizationsSlugWebhooksEndpointsIdDeliveriesGetInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + "page[before]": Schema.optionalKey( + Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + ), + "page[after]": Schema.optionalKey( + Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + ), + "page[size]": Schema.optionalKey( + Schema.String.check( + Schema.isPattern(new RegExp("^\\d+$", "u")).annotate({ + expected: "a string matching the RegExp ^\\d+$", + }), + ), + ), +}); +export const V2OrganizationsSlugWebhooksEndpointsIdDeliveriesGetOutput = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("delivery").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of a delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of a delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + event_id: Schema.String.annotate({ + description: "Identifier of the event, which triggered the delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + status: Schema.Union([ + Schema.Literals(["pending", "success", "failure", "skipped"]).annotate({ + description: "Status of the delivery attempt.", + }), + OpenString, + ]), + response_code: Schema.Number.annotate({ + description: "HTTP status code of the response, `0` if unavailable.", + }) + .check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + response_headers: Schema.Union([ + Schema.Record(Schema.String, Schema.String), + Schema.Null, + ]).annotate({ description: "HTTP headers of the response, `{}` if unavailable." }), + response_body: Schema.Union([ + Schema.Union([ + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Parsed JSON representation of an HTTP body of the response, `{}` if unavailable.", + }), + Schema.String.annotate({ + description: "String representation of an HTTP body of the response.", + }), + ]), + Schema.Null, + ]), + attempt_timestamp: Schema.String.annotate({ + description: "Timestamp of the attempt.", + format: "date-time", + }), + }), + }), + ), + links: Schema.Struct({ + first: Schema.optionalKey( + Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the first page if available.", + }), + ), + prev: Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the previous page.", + }), + next: Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the next page.", + }), + last: Schema.optionalKey( + Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the last page if available.", + }), + ), + }), +}); +export const V2OrganizationsSlugWebhooksEndpointsIdGetInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), +}); +export const V2OrganizationsSlugWebhooksEndpointsIdGetOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + custom_headers: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), +}); +export const V2OrganizationsSlugWebhooksEndpointsIdPatchInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + url: Schema.optionalKey( + Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + ), + enabled: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + ), + event_types: Schema.optionalKey( + Schema.Array( + Schema.Struct({ + type: Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check( + Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), + ), + ), + custom_headers: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + signing_secret: Schema.optionalKey( + Schema.String.annotate({ + description: + "Secret key to use when signing. All events will be signed according to the [Standard Webhooks](https://www.standardwebhooks.com/) specification.", + }) + .check( + Schema.isMinCodePoints(8).annotate({ + expected: "a string with at least 8 code points", + }), + ) + .check( + Schema.isMaxCodePoints(64).annotate({ + expected: "a string with at most 64 code points", + }), + ), + ), + }), + }), +}); +export const V2OrganizationsSlugWebhooksEndpointsIdPatchOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + custom_headers: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), +}); +export const V2OrganizationsSlugWebhooksEndpointsIdTestPostInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + data: Schema.optionalKey( + Schema.Struct({ + type: Schema.Literal("event").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + type: Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "project.v1.paused", + "project.v1.created", + "project.v1.restored", + "project.v1.transferred", + "project.v1.removed", + "project.v1.restarted", + "project.v1.status.changed", + "project.v1.backup.started", + "project.v1.branch.created", + "project.v1.branch.updated", + "project.v1.branch.removed", + "organization.v1.member.invitation.created", + "organization.v1.member.invitation.canceled", + "organization.v1.member.added", + "organization.v1.member.removed", + "organization.v1.member.role.assigned", + "organization.v1.member.role.removed", + "organization.v1.member.role.updated", + "organization.v1.billing.plan.upgraded", + "organization.v1.billing.plan.downgraded", + "project.v1.branch.deleted", + ]).annotate({ description: "Webhook event type." }), + }), + }), + ), +}); +export const V2OrganizationsSlugWebhooksEndpointsIdTestPostOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("ingress").annotate({ description: "Resource type." }), + id: Schema.String, + }), +}); +export const V2OrganizationsSlugWebhooksEndpointsPostInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + ), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + custom_headers: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + signing_secret: Schema.String.annotate({ + description: + "Secret key to use when signing. All events will be signed according to the [Standard Webhooks](https://www.standardwebhooks.com/) specification.", + }) + .check( + Schema.isMinCodePoints(8).annotate({ expected: "a string with at least 8 code points" }), + ) + .check( + Schema.isMaxCodePoints(64).annotate({ expected: "a string with at most 64 code points" }), + ), + }), + }), +}); +export const V2OrganizationsSlugWebhooksEndpointsPostOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + custom_headers: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), +}); +export const V2PreviewAProjectTransferInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + data: Schema.Struct({ + type: Schema.Literal("project_transfer_input").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ target_organization_slug: Schema.String }), + }), +}); +export const V2PreviewAProjectTransferOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("project_transfer_result").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + valid: Schema.Boolean, + warnings: Schema.Array(Schema.Struct({ key: Schema.String, message: Schema.String })), + errors: Schema.Array(Schema.Struct({ key: Schema.String, message: Schema.String })), + info: Schema.Array(Schema.Struct({ key: Schema.String, message: Schema.String })), + }), + }), +}); +export const V2ProjectsRefWebhooksDeliveriesIdGetInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), +}); +export const V2ProjectsRefWebhooksDeliveriesIdGetOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("delivery").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of a delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of a delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + event_id: Schema.String.annotate({ + description: "Identifier of the event, which triggered the delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + status: Schema.Union([ + Schema.Literals(["pending", "success", "failure", "skipped"]).annotate({ + description: "Status of the delivery attempt.", + }), + OpenString, + ]), + response_code: Schema.Number.annotate({ + description: "HTTP status code of the response, `0` if unavailable.", + }) + .check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + response_headers: Schema.Union([ + Schema.Record(Schema.String, Schema.String), + Schema.Null, + ]).annotate({ description: "HTTP headers of the response, `{}` if unavailable." }), + response_body: Schema.Union([ + Schema.Union([ + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Parsed JSON representation of an HTTP body of the response, `{}` if unavailable.", + }), + Schema.String.annotate({ + description: "String representation of an HTTP body of the response.", + }), + ]), + Schema.Null, + ]), + attempt_timestamp: Schema.String.annotate({ + description: "Timestamp of the attempt.", + format: "date-time", + }), + event: Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "project.v1.paused", + "project.v1.created", + "project.v1.restored", + "project.v1.transferred", + "project.v1.removed", + "project.v1.restarted", + "project.v1.status.changed", + "project.v1.backup.started", + "project.v1.branch.created", + "project.v1.branch.updated", + "project.v1.branch.removed", + "organization.v1.member.invitation.created", + "organization.v1.member.invitation.canceled", + "organization.v1.member.added", + "organization.v1.member.removed", + "organization.v1.member.role.assigned", + "organization.v1.member.role.removed", + "organization.v1.member.role.updated", + "organization.v1.billing.plan.upgraded", + "organization.v1.billing.plan.downgraded", + "project.v1.branch.deleted", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + payload: Schema.Struct({ + organization_slug: Schema.String.annotate({ description: "Organization slug" }).check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + project_ref: Schema.Union([ + Schema.String.annotate({ description: "Project ref" }) + .check( + Schema.isMinCodePoints(20).annotate({ + expected: "a string with at least 20 code points", + }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ + expected: "a string with at most 20 code points", + }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + Schema.Null, + ]), + }).annotate({ description: "Final data sent to the consumer." }), + timestamp: Schema.String.annotate({ + description: "Timestamp of event publication.", + format: "date-time", + }), + }), + }), + }), +}); +export const V2ProjectsRefWebhooksDeliveriesIdRetryPostInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), +}); +export const V2ProjectsRefWebhooksDeliveriesIdRetryPostOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("ingress").annotate({ description: "Resource type." }), + id: Schema.String, + }), +}); +export const V2ProjectsRefWebhooksEndpointsDeleteInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), +}); +export const V2ProjectsRefWebhooksEndpointsDeleteOutput = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check( + Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), + ), + custom_headers: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), + ), +}); +export const V2ProjectsRefWebhooksEndpointsGetInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + "page[offset]": Schema.optionalKey( + Schema.String.check( + Schema.isPattern(new RegExp("^\\d+$", "u")).annotate({ + expected: "a string matching the RegExp ^\\d+$", + }), + ), + ), + "page[limit]": Schema.optionalKey( + Schema.String.check( + Schema.isPattern(new RegExp("^\\d+$", "u")).annotate({ + expected: "a string matching the RegExp ^\\d+$", + }), + ), + ), +}); +export const V2ProjectsRefWebhooksEndpointsGetOutput = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check( + Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), + ), + custom_headers: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), + ), + links: Schema.Struct({ + first: Schema.optionalKey( + Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the first page if available.", + }), + ), + prev: Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the previous page.", + }), + next: Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the next page.", + }), + last: Schema.optionalKey( + Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the last page if available.", + }), + ), + }), +}); +export const V2ProjectsRefWebhooksEndpointsIdDeleteInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), +}); +export const V2ProjectsRefWebhooksEndpointsIdDeleteOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + custom_headers: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), +}); +export const V2ProjectsRefWebhooksEndpointsIdDeliveriesGetInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + "page[before]": Schema.optionalKey( + Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + ), + "page[after]": Schema.optionalKey( + Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + ), + "page[size]": Schema.optionalKey( + Schema.String.check( + Schema.isPattern(new RegExp("^\\d+$", "u")).annotate({ + expected: "a string matching the RegExp ^\\d+$", + }), + ), + ), +}); +export const V2ProjectsRefWebhooksEndpointsIdDeliveriesGetOutput = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("delivery").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of a delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of a delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + event_id: Schema.String.annotate({ + description: "Identifier of the event, which triggered the delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + status: Schema.Union([ + Schema.Literals(["pending", "success", "failure", "skipped"]).annotate({ + description: "Status of the delivery attempt.", + }), + OpenString, + ]), + response_code: Schema.Number.annotate({ + description: "HTTP status code of the response, `0` if unavailable.", + }) + .check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + response_headers: Schema.Union([ + Schema.Record(Schema.String, Schema.String), + Schema.Null, + ]).annotate({ description: "HTTP headers of the response, `{}` if unavailable." }), + response_body: Schema.Union([ + Schema.Union([ + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Parsed JSON representation of an HTTP body of the response, `{}` if unavailable.", + }), + Schema.String.annotate({ + description: "String representation of an HTTP body of the response.", + }), + ]), + Schema.Null, + ]), + attempt_timestamp: Schema.String.annotate({ + description: "Timestamp of the attempt.", + format: "date-time", + }), + }), + }), + ), + links: Schema.Struct({ + first: Schema.optionalKey( + Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the first page if available.", + }), + ), + prev: Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the previous page.", + }), + next: Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the next page.", + }), + last: Schema.optionalKey( + Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the last page if available.", + }), + ), + }), +}); +export const V2ProjectsRefWebhooksEndpointsIdGetInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), +}); +export const V2ProjectsRefWebhooksEndpointsIdGetOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + custom_headers: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), +}); +export const V2ProjectsRefWebhooksEndpointsIdPatchInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + url: Schema.optionalKey( + Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + ), + enabled: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + ), + event_types: Schema.optionalKey( + Schema.Array( + Schema.Struct({ + type: Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check( + Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), + ), + ), + custom_headers: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + signing_secret: Schema.optionalKey( + Schema.String.annotate({ + description: + "Secret key to use when signing. All events will be signed according to the [Standard Webhooks](https://www.standardwebhooks.com/) specification.", + }) + .check( + Schema.isMinCodePoints(8).annotate({ + expected: "a string with at least 8 code points", + }), + ) + .check( + Schema.isMaxCodePoints(64).annotate({ + expected: "a string with at most 64 code points", + }), + ), + ), + }), + }), +}); +export const V2ProjectsRefWebhooksEndpointsIdPatchOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + custom_headers: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), +}); +export const V2ProjectsRefWebhooksEndpointsIdTestPostInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + data: Schema.optionalKey( + Schema.Struct({ + type: Schema.Literal("event").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + type: Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "project.v1.paused", + "project.v1.created", + "project.v1.restored", + "project.v1.transferred", + "project.v1.removed", + "project.v1.restarted", + "project.v1.status.changed", + "project.v1.backup.started", + "project.v1.branch.created", + "project.v1.branch.updated", + "project.v1.branch.removed", + "organization.v1.member.invitation.created", + "organization.v1.member.invitation.canceled", + "organization.v1.member.added", + "organization.v1.member.removed", + "organization.v1.member.role.assigned", + "organization.v1.member.role.removed", + "organization.v1.member.role.updated", + "organization.v1.billing.plan.upgraded", + "organization.v1.billing.plan.downgraded", + "project.v1.branch.deleted", + ]).annotate({ description: "Webhook event type." }), + }), + }), + ), +}); +export const V2ProjectsRefWebhooksEndpointsIdTestPostOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("ingress").annotate({ description: "Resource type." }), + id: Schema.String, + }), +}); +export const V2ProjectsRefWebhooksEndpointsPostInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + ), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + custom_headers: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + signing_secret: Schema.String.annotate({ + description: + "Secret key to use when signing. All events will be signed according to the [Standard Webhooks](https://www.standardwebhooks.com/) specification.", + }) + .check( + Schema.isMinCodePoints(8).annotate({ expected: "a string with at least 8 code points" }), + ) + .check( + Schema.isMaxCodePoints(64).annotate({ expected: "a string with at most 64 code points" }), + ), + }), + }), +}); +export const V2ProjectsRefWebhooksEndpointsPostOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", }), ), - data: Schema.Struct({ - type: Schema.Literal("project_transfer_input").annotate({ description: "Resource type." }), - attributes: Schema.Struct({ target_organization_slug: Schema.String }), - }), -}); -export const V2PreviewAProjectTransferOutput = Schema.Struct({ - data: Schema.Struct({ - type: Schema.Literal("project_transfer_result").annotate({ description: "Resource type." }), attributes: Schema.Struct({ - valid: Schema.Boolean, - warnings: Schema.Array(Schema.Struct({ key: Schema.String, message: Schema.String })), - errors: Schema.Array(Schema.Struct({ key: Schema.String, message: Schema.String })), - info: Schema.Array(Schema.Struct({ key: Schema.String, message: Schema.String })), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + custom_headers: Schema.optionalKey( + Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), }), }), }); @@ -13755,54 +16877,59 @@ export const V2RunProjectAdvisorsOutput = Schema.Struct({ attributes: Schema.Struct({ lints: Schema.Array( Schema.Struct({ - name: Schema.Literals([ - "unindexed_foreign_keys", - "auth_users_exposed", - "auth_rls_initplan", - "no_primary_key", - "unused_index", - "multiple_permissive_policies", - "policy_exists_rls_disabled", - "rls_enabled_no_policy", - "duplicate_index", - "security_definer_view", - "function_search_path_mutable", - "rls_disabled_in_public", - "extension_in_public", - "rls_references_user_metadata", - "materialized_view_in_api", - "foreign_table_in_api", - "unsupported_reg_types", - "auth_otp_long_expiry", - "auth_otp_short_length", - "ssl_not_enforced", - "log_connections_not_enabled", - "network_restrictions_not_set", - "password_requirements_min_length", - "pitr_not_enabled", - "auth_leaked_password_protection", - "auth_insufficient_mfa_options", - "auth_password_policy_missing", - "leaked_service_key", - "no_backup_admin", - "vulnerable_postgres_version", - "db_not_reachable", - "db_connection_failing", - "db_connection_limit_reached", - "instance_telemetry_lost", - "instance_db_down", - "instance_alert_firing", - "log_data_api_error_rate_high", - "log_auth_error_rate_high", - "log_storage_error_rate_high", - "log_edge_function_error_rate_high", - "project_not_active", - "advisor_check_unavailable", + name: Schema.Union([ + Schema.Literals([ + "unindexed_foreign_keys", + "auth_users_exposed", + "auth_rls_initplan", + "no_primary_key", + "unused_index", + "multiple_permissive_policies", + "policy_exists_rls_disabled", + "rls_enabled_no_policy", + "duplicate_index", + "security_definer_view", + "function_search_path_mutable", + "rls_disabled_in_public", + "extension_in_public", + "rls_references_user_metadata", + "materialized_view_in_api", + "foreign_table_in_api", + "unsupported_reg_types", + "auth_otp_long_expiry", + "auth_otp_short_length", + "ssl_not_enforced", + "log_connections_not_enabled", + "network_restrictions_not_set", + "password_requirements_min_length", + "pitr_not_enabled", + "auth_leaked_password_protection", + "auth_insufficient_mfa_options", + "auth_password_policy_missing", + "leaked_service_key", + "no_backup_admin", + "vulnerable_postgres_version", + "db_not_reachable", + "db_connection_failing", + "db_connection_limit_reached", + "instance_telemetry_lost", + "instance_db_down", + "instance_alert_firing", + "log_data_api_error_rate_high", + "log_auth_error_rate_high", + "log_storage_error_rate_high", + "log_edge_function_error_rate_high", + "project_not_active", + "advisor_check_unavailable", + ]), + OpenString, ]), title: Schema.String, - level: Schema.Literals(["ERROR", "WARN", "INFO"]), + level: Schema.Union([Schema.Literals(["ERROR", "WARN", "INFO"]), OpenString]), facing: Schema.Literal("EXTERNAL"), - categories: Schema.Array(Schema.Literals(["PERFORMANCE", "SECURITY", "HEALTH"])), + categories: Schema.Array( + Schema.Union([Schema.Literals(["PERFORMANCE", "SECURITY", "HEALTH"]), OpenString]), + ), description: Schema.String, detail: Schema.String, remediation: Schema.String, @@ -13812,16 +16939,19 @@ export const V2RunProjectAdvisorsOutput = Schema.Struct({ name: Schema.optionalKey(Schema.String), entity: Schema.optionalKey(Schema.String), type: Schema.optionalKey( - Schema.Literals([ - "table", - "view", - "materialized view", - "foreign table", - "auth", - "function", - "extension", - "compliance", - "health", + Schema.Union([ + Schema.Literals([ + "table", + "view", + "materialized view", + "foreign table", + "auth", + "function", + "extension", + "compliance", + "health", + ]), + OpenString, ]), ), fkey_name: Schema.optionalKey(Schema.String), @@ -13992,7 +17122,7 @@ export const V2UpdateLogDrainOutput = Schema.Struct({ config: Schema.Union([ Schema.Struct({ url: Schema.optionalKey(Schema.String), - http: Schema.optionalKey(Schema.Literals(["http1", "http2"])), + http: Schema.optionalKey(Schema.Union([Schema.Literals(["http1", "http2"]), OpenString])), gzip: Schema.optionalKey(Schema.Boolean), headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }).annotate({ title: "webhook" }), @@ -14065,19 +17195,22 @@ export const V2UpdateLogDrainOutput = Schema.Struct({ headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }).annotate({ title: "otlp" }), ]), - backend_type: Schema.Literals([ - "postgres", - "bigquery", - "clickhouse", - "webhook", - "datadog", - "loki", - "sentry", - "s3", - "axiom", - "last9", - "otlp", - "syslog", + backend_type: Schema.Union([ + Schema.Literals([ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog", + ]), + OpenString, ]), }), }), @@ -14380,10 +17513,12 @@ export const V2UpdateNotebookOutput = Schema.Struct({ }), ), title: Schema.optionalKey(Schema.String), - view: Schema.optionalKey(Schema.Literals(["table", "chart"])), + view: Schema.optionalKey( + Schema.Union([Schema.Literals(["table", "chart"]), OpenString]), + ), chart: Schema.optionalKey( Schema.Struct({ - type: Schema.Literals(["bar", "line"]), + type: Schema.Union([Schema.Literals(["bar", "line"]), OpenString]), x_column: Schema.String.annotate({ description: "Result column used for the x axis.", }), @@ -14394,7 +17529,7 @@ export const V2UpdateNotebookOutput = Schema.Struct({ }), }), ), - scale: Schema.Literals(["linear", "log"]), + scale: Schema.Union([Schema.Literals(["linear", "log"]), OpenString]), cumulative: Schema.Boolean, show_labels: Schema.Boolean, }).annotate({ @@ -14417,7 +17552,10 @@ export const V2UpdateNotebookOutput = Schema.Struct({ }), Schema.Struct({ type: Schema.Literal("relative"), - unit: Schema.Literals(["minute", "hour", "day", "week", "month", "year"]), + unit: Schema.Union([ + Schema.Literals(["minute", "hour", "day", "week", "month", "year"]), + OpenString, + ]), amount: Schema.Number.check( Schema.isInt().annotate({ expected: "an integer" }), ) @@ -14434,10 +17572,12 @@ export const V2UpdateNotebookOutput = Schema.Struct({ { mode: "oneOf" }, ), title: Schema.optionalKey(Schema.String), - view: Schema.optionalKey(Schema.Literals(["table", "chart"])), + view: Schema.optionalKey( + Schema.Union([Schema.Literals(["table", "chart"]), OpenString]), + ), chart: Schema.optionalKey( Schema.Struct({ - type: Schema.Literals(["bar", "line"]), + type: Schema.Union([Schema.Literals(["bar", "line"]), OpenString]), x_column: Schema.String.annotate({ description: "Result column used for the x axis.", }), @@ -14448,7 +17588,7 @@ export const V2UpdateNotebookOutput = Schema.Struct({ }), }), ), - scale: Schema.Literals(["linear", "log"]), + scale: Schema.Union([Schema.Literals(["linear", "log"]), OpenString]), cumulative: Schema.Boolean, show_labels: Schema.Boolean, }).annotate({ @@ -14706,7 +17846,33 @@ export const openApiOperationIdMap = { "v2-list-organization-projects": "v2ListOrganizationProjects", "v2-list-organization-roles": "v2ListOrganizationRoles", "v2-list-private-link-associations": "v2ListPrivateLinkAssociations", + "v2-organizations-slug-webhooks-deliveries-id-get": "v2OrganizationsSlugWebhooksDeliveriesIdGet", + "v2-organizations-slug-webhooks-deliveries-id-retry-post": + "v2OrganizationsSlugWebhooksDeliveriesIdRetryPost", + "v2-organizations-slug-webhooks-endpoints-delete": "v2OrganizationsSlugWebhooksEndpointsDelete", + "v2-organizations-slug-webhooks-endpoints-get": "v2OrganizationsSlugWebhooksEndpointsGet", + "v2-organizations-slug-webhooks-endpoints-id-delete": + "v2OrganizationsSlugWebhooksEndpointsIdDelete", + "v2-organizations-slug-webhooks-endpoints-id-deliveries-get": + "v2OrganizationsSlugWebhooksEndpointsIdDeliveriesGet", + "v2-organizations-slug-webhooks-endpoints-id-get": "v2OrganizationsSlugWebhooksEndpointsIdGet", + "v2-organizations-slug-webhooks-endpoints-id-patch": + "v2OrganizationsSlugWebhooksEndpointsIdPatch", + "v2-organizations-slug-webhooks-endpoints-id-test-post": + "v2OrganizationsSlugWebhooksEndpointsIdTestPost", + "v2-organizations-slug-webhooks-endpoints-post": "v2OrganizationsSlugWebhooksEndpointsPost", "v2-preview-a-project-transfer": "v2PreviewAProjectTransfer", + "v2-projects-ref-webhooks-deliveries-id-get": "v2ProjectsRefWebhooksDeliveriesIdGet", + "v2-projects-ref-webhooks-deliveries-id-retry-post": "v2ProjectsRefWebhooksDeliveriesIdRetryPost", + "v2-projects-ref-webhooks-endpoints-delete": "v2ProjectsRefWebhooksEndpointsDelete", + "v2-projects-ref-webhooks-endpoints-get": "v2ProjectsRefWebhooksEndpointsGet", + "v2-projects-ref-webhooks-endpoints-id-delete": "v2ProjectsRefWebhooksEndpointsIdDelete", + "v2-projects-ref-webhooks-endpoints-id-deliveries-get": + "v2ProjectsRefWebhooksEndpointsIdDeliveriesGet", + "v2-projects-ref-webhooks-endpoints-id-get": "v2ProjectsRefWebhooksEndpointsIdGet", + "v2-projects-ref-webhooks-endpoints-id-patch": "v2ProjectsRefWebhooksEndpointsIdPatch", + "v2-projects-ref-webhooks-endpoints-id-test-post": "v2ProjectsRefWebhooksEndpointsIdTestPost", + "v2-projects-ref-webhooks-endpoints-post": "v2ProjectsRefWebhooksEndpointsPost", "v2-run-project-advisors": "v2RunProjectAdvisors", "v2-transfer-a-project": "v2TransferAProject", "v2-update-log-drain": "v2UpdateLogDrain", @@ -17802,6 +20968,141 @@ export const operationDefinitions = { inputSchema: V2ListPrivateLinkAssociationsInput, outputSchema: V2ListPrivateLinkAssociationsOutput, }, + v2OrganizationsSlugWebhooksDeliveriesIdGet: { + id: "v2OrganizationsSlugWebhooksDeliveriesIdGet", + description: "Get details of a specific delivery attempt.", + method: "GET", + path: "/v2/organizations/{slug}/webhooks/deliveries/{id}", + pathParams: ["slug", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2OrganizationsSlugWebhooksDeliveriesIdGetInput, + outputSchema: V2OrganizationsSlugWebhooksDeliveriesIdGetOutput, + }, + v2OrganizationsSlugWebhooksDeliveriesIdRetryPost: { + id: "v2OrganizationsSlugWebhooksDeliveriesIdRetryPost", + description: + "Retry delivering the same event again.\n\nAutomatic retries are not applicable to manual retries - if the delivery fails, there won't be any automatic retries attempted.\n\nThis endpoint is heavy rate-limited to allow for 10 request within 60 seconds.", + method: "POST", + path: "/v2/organizations/{slug}/webhooks/deliveries/{id}/retry", + pathParams: ["slug", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2OrganizationsSlugWebhooksDeliveriesIdRetryPostInput, + outputSchema: V2OrganizationsSlugWebhooksDeliveriesIdRetryPostOutput, + }, + v2OrganizationsSlugWebhooksEndpointsDelete: { + id: "v2OrganizationsSlugWebhooksEndpointsDelete", + description: + "Delete all endpoints including all events and deliveries.\n\nAny in-flight webhooks will result in a no-op.", + method: "DELETE", + path: "/v2/organizations/{slug}/webhooks/endpoints", + pathParams: ["slug"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2OrganizationsSlugWebhooksEndpointsDeleteInput, + outputSchema: V2OrganizationsSlugWebhooksEndpointsDeleteOutput, + }, + v2OrganizationsSlugWebhooksEndpointsGet: { + id: "v2OrganizationsSlugWebhooksEndpointsGet", + description: "List all Webhook endpoints based on a project's ref or an organization's slug.", + method: "GET", + path: "/v2/organizations/{slug}/webhooks/endpoints", + pathParams: ["slug"], + queryParams: ["page[offset]", "page[limit]"], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2OrganizationsSlugWebhooksEndpointsGetInput, + outputSchema: V2OrganizationsSlugWebhooksEndpointsGetOutput, + }, + v2OrganizationsSlugWebhooksEndpointsIdDelete: { + id: "v2OrganizationsSlugWebhooksEndpointsIdDelete", + description: + "Delete the endpoint including all events and deliveries\n\nAny in-flight webhooks will result in a no-op.", + method: "DELETE", + path: "/v2/organizations/{slug}/webhooks/endpoints/{id}", + pathParams: ["slug", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2OrganizationsSlugWebhooksEndpointsIdDeleteInput, + outputSchema: V2OrganizationsSlugWebhooksEndpointsIdDeleteOutput, + }, + v2OrganizationsSlugWebhooksEndpointsIdDeliveriesGet: { + id: "v2OrganizationsSlugWebhooksEndpointsIdDeliveriesGet", + description: + "List all deliveries for a specific endpoint in descending order (newest first).\n\nDeliveries which has expired are no longer available and will not be listed.", + method: "GET", + path: "/v2/organizations/{slug}/webhooks/endpoints/{id}/deliveries", + pathParams: ["slug", "id"], + queryParams: ["page[before]", "page[after]", "page[size]"], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2OrganizationsSlugWebhooksEndpointsIdDeliveriesGetInput, + outputSchema: V2OrganizationsSlugWebhooksEndpointsIdDeliveriesGetOutput, + }, + v2OrganizationsSlugWebhooksEndpointsIdGet: { + id: "v2OrganizationsSlugWebhooksEndpointsIdGet", + description: "Get details of a specific endpoint.", + method: "GET", + path: "/v2/organizations/{slug}/webhooks/endpoints/{id}", + pathParams: ["slug", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2OrganizationsSlugWebhooksEndpointsIdGetInput, + outputSchema: V2OrganizationsSlugWebhooksEndpointsIdGetOutput, + }, + v2OrganizationsSlugWebhooksEndpointsIdPatch: { + id: "v2OrganizationsSlugWebhooksEndpointsIdPatch", + description: "Update endpoint's configuration.", + method: "PATCH", + path: "/v2/organizations/{slug}/webhooks/endpoints/{id}", + pathParams: ["slug", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["data"] }, + response: { kind: "json" }, + inputSchema: V2OrganizationsSlugWebhooksEndpointsIdPatchInput, + outputSchema: V2OrganizationsSlugWebhooksEndpointsIdPatchOutput, + }, + v2OrganizationsSlugWebhooksEndpointsIdTestPost: { + id: "v2OrganizationsSlugWebhooksEndpointsIdTestPost", + description: + "Ingests and schedules a test webhook event to be published out this endpoints.\n\nWhich event type to use can be specified in the request body, otherwise\nit will use any matching type the endpoint is listening for.\n\nThe event will contain `is_test: true` in it's payload.\n\nThis endpoint is heavy rate-limited to allow for 10 request within 60 seconds.", + method: "POST", + path: "/v2/organizations/{slug}/webhooks/endpoints/{id}/test", + pathParams: ["slug", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["data"] }, + response: { kind: "json" }, + inputSchema: V2OrganizationsSlugWebhooksEndpointsIdTestPostInput, + outputSchema: V2OrganizationsSlugWebhooksEndpointsIdTestPostOutput, + }, + v2OrganizationsSlugWebhooksEndpointsPost: { + id: "v2OrganizationsSlugWebhooksEndpointsPost", + description: "Create new endpoint configuration to subscribe to specific webhook events.", + method: "POST", + path: "/v2/organizations/{slug}/webhooks/endpoints", + pathParams: ["slug"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["data"] }, + response: { kind: "json" }, + inputSchema: V2OrganizationsSlugWebhooksEndpointsPostInput, + outputSchema: V2OrganizationsSlugWebhooksEndpointsPostOutput, + }, v2PreviewAProjectTransfer: { id: "v2PreviewAProjectTransfer", description: @@ -17816,6 +21117,141 @@ export const operationDefinitions = { inputSchema: V2PreviewAProjectTransferInput, outputSchema: V2PreviewAProjectTransferOutput, }, + v2ProjectsRefWebhooksDeliveriesIdGet: { + id: "v2ProjectsRefWebhooksDeliveriesIdGet", + description: "Get details of a specific delivery attempt.", + method: "GET", + path: "/v2/projects/{ref}/webhooks/deliveries/{id}", + pathParams: ["ref", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2ProjectsRefWebhooksDeliveriesIdGetInput, + outputSchema: V2ProjectsRefWebhooksDeliveriesIdGetOutput, + }, + v2ProjectsRefWebhooksDeliveriesIdRetryPost: { + id: "v2ProjectsRefWebhooksDeliveriesIdRetryPost", + description: + "Retry delivering the same event again.\n\nAutomatic retries are not applicable to manual retries - if the delivery fails, there won't be any automatic retries attempted.\n\nThis endpoint is heavy rate-limited to allow for 10 request within 60 seconds.", + method: "POST", + path: "/v2/projects/{ref}/webhooks/deliveries/{id}/retry", + pathParams: ["ref", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2ProjectsRefWebhooksDeliveriesIdRetryPostInput, + outputSchema: V2ProjectsRefWebhooksDeliveriesIdRetryPostOutput, + }, + v2ProjectsRefWebhooksEndpointsDelete: { + id: "v2ProjectsRefWebhooksEndpointsDelete", + description: + "Delete all endpoints including all events and deliveries.\n\nAny in-flight webhooks will result in a no-op.", + method: "DELETE", + path: "/v2/projects/{ref}/webhooks/endpoints", + pathParams: ["ref"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2ProjectsRefWebhooksEndpointsDeleteInput, + outputSchema: V2ProjectsRefWebhooksEndpointsDeleteOutput, + }, + v2ProjectsRefWebhooksEndpointsGet: { + id: "v2ProjectsRefWebhooksEndpointsGet", + description: "List all Webhook endpoints based on a project's ref or an organization's slug.", + method: "GET", + path: "/v2/projects/{ref}/webhooks/endpoints", + pathParams: ["ref"], + queryParams: ["page[offset]", "page[limit]"], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2ProjectsRefWebhooksEndpointsGetInput, + outputSchema: V2ProjectsRefWebhooksEndpointsGetOutput, + }, + v2ProjectsRefWebhooksEndpointsIdDelete: { + id: "v2ProjectsRefWebhooksEndpointsIdDelete", + description: + "Delete the endpoint including all events and deliveries\n\nAny in-flight webhooks will result in a no-op.", + method: "DELETE", + path: "/v2/projects/{ref}/webhooks/endpoints/{id}", + pathParams: ["ref", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2ProjectsRefWebhooksEndpointsIdDeleteInput, + outputSchema: V2ProjectsRefWebhooksEndpointsIdDeleteOutput, + }, + v2ProjectsRefWebhooksEndpointsIdDeliveriesGet: { + id: "v2ProjectsRefWebhooksEndpointsIdDeliveriesGet", + description: + "List all deliveries for a specific endpoint in descending order (newest first).\n\nDeliveries which has expired are no longer available and will not be listed.", + method: "GET", + path: "/v2/projects/{ref}/webhooks/endpoints/{id}/deliveries", + pathParams: ["ref", "id"], + queryParams: ["page[before]", "page[after]", "page[size]"], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2ProjectsRefWebhooksEndpointsIdDeliveriesGetInput, + outputSchema: V2ProjectsRefWebhooksEndpointsIdDeliveriesGetOutput, + }, + v2ProjectsRefWebhooksEndpointsIdGet: { + id: "v2ProjectsRefWebhooksEndpointsIdGet", + description: "Get details of a specific endpoint.", + method: "GET", + path: "/v2/projects/{ref}/webhooks/endpoints/{id}", + pathParams: ["ref", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2ProjectsRefWebhooksEndpointsIdGetInput, + outputSchema: V2ProjectsRefWebhooksEndpointsIdGetOutput, + }, + v2ProjectsRefWebhooksEndpointsIdPatch: { + id: "v2ProjectsRefWebhooksEndpointsIdPatch", + description: "Update endpoint's configuration.", + method: "PATCH", + path: "/v2/projects/{ref}/webhooks/endpoints/{id}", + pathParams: ["ref", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["data"] }, + response: { kind: "json" }, + inputSchema: V2ProjectsRefWebhooksEndpointsIdPatchInput, + outputSchema: V2ProjectsRefWebhooksEndpointsIdPatchOutput, + }, + v2ProjectsRefWebhooksEndpointsIdTestPost: { + id: "v2ProjectsRefWebhooksEndpointsIdTestPost", + description: + "Ingests and schedules a test webhook event to be published out this endpoints.\n\nWhich event type to use can be specified in the request body, otherwise\nit will use any matching type the endpoint is listening for.\n\nThe event will contain `is_test: true` in it's payload.\n\nThis endpoint is heavy rate-limited to allow for 10 request within 60 seconds.", + method: "POST", + path: "/v2/projects/{ref}/webhooks/endpoints/{id}/test", + pathParams: ["ref", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["data"] }, + response: { kind: "json" }, + inputSchema: V2ProjectsRefWebhooksEndpointsIdTestPostInput, + outputSchema: V2ProjectsRefWebhooksEndpointsIdTestPostOutput, + }, + v2ProjectsRefWebhooksEndpointsPost: { + id: "v2ProjectsRefWebhooksEndpointsPost", + description: "Create new endpoint configuration to subscribe to specific webhook events.", + method: "POST", + path: "/v2/projects/{ref}/webhooks/endpoints", + pathParams: ["ref"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["data"] }, + response: { kind: "json" }, + inputSchema: V2ProjectsRefWebhooksEndpointsPostInput, + outputSchema: V2ProjectsRefWebhooksEndpointsPostOutput, + }, v2RunProjectAdvisors: { id: "v2RunProjectAdvisors", description: "Runs the project advisors with the given names", diff --git a/packages/api/src/generated/effect-client.ts b/packages/api/src/generated/effect-client.ts index 57a9646ec9..d1f8bd2150 100644 --- a/packages/api/src/generated/effect-client.ts +++ b/packages/api/src/generated/effect-client.ts @@ -1,7 +1,6 @@ -import { Effect, Schema } from "effect"; +import { Effect } from "effect"; -import type { EffectClient } from "../internal/effect-client.ts"; -import type { SupabaseApiError } from "../internal/client.ts"; +import type { SupabaseApiError, SupabaseApiRequestOptions } from "../internal/client.ts"; import { SupabaseApiClient } from "../internal/client.ts"; import { operationDefinitions } from "./contracts.ts"; @@ -9,6 +8,7 @@ export const versionedEffectOperations = { v1: { acceptInviteExternalJitAccess: ( input: typeof operationDefinitions.v1AcceptInviteExternalJitAccess.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1AcceptInviteExternalJitAccess.outputSchema.Type, SupabaseApiError, @@ -19,10 +19,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1AcceptInviteExternalJitAccess">( operationDefinitions.v1AcceptInviteExternalJitAccess, input, + options, ); }), activateCustomHostname: ( input: typeof operationDefinitions.v1ActivateCustomHostname.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ActivateCustomHostname.outputSchema.Type, SupabaseApiError, @@ -33,10 +35,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ActivateCustomHostname">( operationDefinitions.v1ActivateCustomHostname, input, + options, ); }), activateVanitySubdomainConfig: ( input: typeof operationDefinitions.v1ActivateVanitySubdomainConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ActivateVanitySubdomainConfig.outputSchema.Type, SupabaseApiError, @@ -47,10 +51,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ActivateVanitySubdomainConfig">( operationDefinitions.v1ActivateVanitySubdomainConfig, input, + options, ); }), applyAMigration: ( input: typeof operationDefinitions.v1ApplyAMigration.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ApplyAMigration.outputSchema.Type, SupabaseApiError, @@ -61,10 +67,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ApplyAMigration">( operationDefinitions.v1ApplyAMigration, input, + options, ); }), applyProjectAddon: ( input: typeof operationDefinitions.v1ApplyProjectAddon.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ApplyProjectAddon.outputSchema.Type, SupabaseApiError, @@ -75,10 +83,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ApplyProjectAddon">( operationDefinitions.v1ApplyProjectAddon, input, + options, ); }), authorizeJitAccess: ( input: typeof operationDefinitions.v1AuthorizeJitAccess.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1AuthorizeJitAccess.outputSchema.Type, SupabaseApiError, @@ -89,10 +99,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1AuthorizeJitAccess">( operationDefinitions.v1AuthorizeJitAccess, input, + options, ); }), authorizeUser: ( input: typeof operationDefinitions.v1AuthorizeUser.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1AuthorizeUser.outputSchema.Type, SupabaseApiError, @@ -103,10 +115,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1AuthorizeUser">( operationDefinitions.v1AuthorizeUser, input, + options, ); }), bulkCreateSecrets: ( input: typeof operationDefinitions.v1BulkCreateSecrets.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1BulkCreateSecrets.outputSchema.Type, SupabaseApiError, @@ -117,10 +131,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1BulkCreateSecrets">( operationDefinitions.v1BulkCreateSecrets, input, + options, ); }), bulkDeleteSecrets: ( input: typeof operationDefinitions.v1BulkDeleteSecrets.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1BulkDeleteSecrets.outputSchema.Type, SupabaseApiError, @@ -131,10 +147,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1BulkDeleteSecrets">( operationDefinitions.v1BulkDeleteSecrets, input, + options, ); }), bulkUpdateFunctions: ( input: typeof operationDefinitions.v1BulkUpdateFunctions.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1BulkUpdateFunctions.outputSchema.Type, SupabaseApiError, @@ -145,10 +163,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1BulkUpdateFunctions">( operationDefinitions.v1BulkUpdateFunctions, input, + options, ); }), cancelAProjectRestoration: ( input: typeof operationDefinitions.v1CancelAProjectRestoration.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CancelAProjectRestoration.outputSchema.Type, SupabaseApiError, @@ -159,10 +179,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CancelAProjectRestoration">( operationDefinitions.v1CancelAProjectRestoration, input, + options, ); }), checkVanitySubdomainAvailability: ( input: typeof operationDefinitions.v1CheckVanitySubdomainAvailability.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CheckVanitySubdomainAvailability.outputSchema.Type, SupabaseApiError, @@ -173,10 +195,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CheckVanitySubdomainAvailability">( operationDefinitions.v1CheckVanitySubdomainAvailability, input, + options, ); }), claimProjectForOrganization: ( input: typeof operationDefinitions.v1ClaimProjectForOrganization.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ClaimProjectForOrganization.outputSchema.Type, SupabaseApiError, @@ -187,10 +211,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ClaimProjectForOrganization">( operationDefinitions.v1ClaimProjectForOrganization, input, + options, ); }), countActionRuns: ( input: typeof operationDefinitions.v1CountActionRuns.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CountActionRuns.outputSchema.Type, SupabaseApiError, @@ -201,10 +227,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CountActionRuns">( operationDefinitions.v1CountActionRuns, input, + options, ); }), createABranch: ( input: typeof operationDefinitions.v1CreateABranch.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateABranch.outputSchema.Type, SupabaseApiError, @@ -215,10 +243,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateABranch">( operationDefinitions.v1CreateABranch, input, + options, ); }), createAFunction: ( input: typeof operationDefinitions.v1CreateAFunction.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateAFunction.outputSchema.Type, SupabaseApiError, @@ -229,10 +259,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateAFunction">( operationDefinitions.v1CreateAFunction, input, + options, ); }), createAProject: ( input: typeof operationDefinitions.v1CreateAProject.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateAProject.outputSchema.Type, SupabaseApiError, @@ -243,10 +275,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateAProject">( operationDefinitions.v1CreateAProject, input, + options, ); }), createASsoProvider: ( input: typeof operationDefinitions.v1CreateASsoProvider.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateASsoProvider.outputSchema.Type, SupabaseApiError, @@ -257,10 +291,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateASsoProvider">( operationDefinitions.v1CreateASsoProvider, input, + options, ); }), createAnOrganization: ( input: typeof operationDefinitions.v1CreateAnOrganization.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateAnOrganization.outputSchema.Type, SupabaseApiError, @@ -271,10 +307,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateAnOrganization">( operationDefinitions.v1CreateAnOrganization, input, + options, ); }), createLegacySigningKey: ( input: typeof operationDefinitions.v1CreateLegacySigningKey.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateLegacySigningKey.outputSchema.Type, SupabaseApiError, @@ -285,10 +323,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateLegacySigningKey">( operationDefinitions.v1CreateLegacySigningKey, input, + options, ); }), createLoginRole: ( input: typeof operationDefinitions.v1CreateLoginRole.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateLoginRole.outputSchema.Type, SupabaseApiError, @@ -299,10 +339,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateLoginRole">( operationDefinitions.v1CreateLoginRole, input, + options, ); }), createProjectApiKey: ( input: typeof operationDefinitions.v1CreateProjectApiKey.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateProjectApiKey.outputSchema.Type, SupabaseApiError, @@ -313,10 +355,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateProjectApiKey">( operationDefinitions.v1CreateProjectApiKey, input, + options, ); }), createProjectClaimToken: ( input: typeof operationDefinitions.v1CreateProjectClaimToken.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateProjectClaimToken.outputSchema.Type, SupabaseApiError, @@ -327,10 +371,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateProjectClaimToken">( operationDefinitions.v1CreateProjectClaimToken, input, + options, ); }), createProjectSigningKey: ( input: typeof operationDefinitions.v1CreateProjectSigningKey.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateProjectSigningKey.outputSchema.Type, SupabaseApiError, @@ -341,10 +387,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateProjectSigningKey">( operationDefinitions.v1CreateProjectSigningKey, input, + options, ); }), createProjectTpaIntegration: ( input: typeof operationDefinitions.v1CreateProjectTpaIntegration.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateProjectTpaIntegration.outputSchema.Type, SupabaseApiError, @@ -355,10 +403,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateProjectTpaIntegration">( operationDefinitions.v1CreateProjectTpaIntegration, input, + options, ); }), createRestorePoint: ( input: typeof operationDefinitions.v1CreateRestorePoint.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateRestorePoint.outputSchema.Type, SupabaseApiError, @@ -369,10 +419,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateRestorePoint">( operationDefinitions.v1CreateRestorePoint, input, + options, ); }), deactivateVanitySubdomainConfig: ( input: typeof operationDefinitions.v1DeactivateVanitySubdomainConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeactivateVanitySubdomainConfig.outputSchema.Type, SupabaseApiError, @@ -383,10 +435,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeactivateVanitySubdomainConfig">( operationDefinitions.v1DeactivateVanitySubdomainConfig, input, + options, ); }), deleteHostnameConfig: ( input: typeof operationDefinitions.v1DeleteHostnameConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteHostnameConfig.outputSchema.Type, SupabaseApiError, @@ -397,10 +451,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteHostnameConfig">( operationDefinitions.v1DeleteHostnameConfig, input, + options, ); }), deleteABranch: ( input: typeof operationDefinitions.v1DeleteABranch.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteABranch.outputSchema.Type, SupabaseApiError, @@ -411,10 +467,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteABranch">( operationDefinitions.v1DeleteABranch, input, + options, ); }), deleteAFunction: ( input: typeof operationDefinitions.v1DeleteAFunction.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteAFunction.outputSchema.Type, SupabaseApiError, @@ -425,10 +483,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteAFunction">( operationDefinitions.v1DeleteAFunction, input, + options, ); }), deleteAProject: ( input: typeof operationDefinitions.v1DeleteAProject.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteAProject.outputSchema.Type, SupabaseApiError, @@ -439,10 +499,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteAProject">( operationDefinitions.v1DeleteAProject, input, + options, ); }), deleteASsoProvider: ( input: typeof operationDefinitions.v1DeleteASsoProvider.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteASsoProvider.outputSchema.Type, SupabaseApiError, @@ -453,10 +515,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteASsoProvider">( operationDefinitions.v1DeleteASsoProvider, input, + options, ); }), deleteInviteExternalJitAccess: ( input: typeof operationDefinitions.v1DeleteInviteExternalJitAccess.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteInviteExternalJitAccess.outputSchema.Type, SupabaseApiError, @@ -467,10 +531,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteInviteExternalJitAccess">( operationDefinitions.v1DeleteInviteExternalJitAccess, input, + options, ); }), deleteJitAccess: ( input: typeof operationDefinitions.v1DeleteJitAccess.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteJitAccess.outputSchema.Type, SupabaseApiError, @@ -481,10 +547,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteJitAccess">( operationDefinitions.v1DeleteJitAccess, input, + options, ); }), deleteLoginRoles: ( input: typeof operationDefinitions.v1DeleteLoginRoles.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteLoginRoles.outputSchema.Type, SupabaseApiError, @@ -495,10 +563,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteLoginRoles">( operationDefinitions.v1DeleteLoginRoles, input, + options, ); }), deleteNetworkBans: ( input: typeof operationDefinitions.v1DeleteNetworkBans.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteNetworkBans.outputSchema.Type, SupabaseApiError, @@ -509,10 +579,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteNetworkBans">( operationDefinitions.v1DeleteNetworkBans, input, + options, ); }), deleteProjectApiKey: ( input: typeof operationDefinitions.v1DeleteProjectApiKey.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteProjectApiKey.outputSchema.Type, SupabaseApiError, @@ -523,10 +595,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteProjectApiKey">( operationDefinitions.v1DeleteProjectApiKey, input, + options, ); }), deleteProjectClaimToken: ( input: typeof operationDefinitions.v1DeleteProjectClaimToken.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteProjectClaimToken.outputSchema.Type, SupabaseApiError, @@ -537,10 +611,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteProjectClaimToken">( operationDefinitions.v1DeleteProjectClaimToken, input, + options, ); }), deleteProjectTpaIntegration: ( input: typeof operationDefinitions.v1DeleteProjectTpaIntegration.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteProjectTpaIntegration.outputSchema.Type, SupabaseApiError, @@ -551,10 +627,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteProjectTpaIntegration">( operationDefinitions.v1DeleteProjectTpaIntegration, input, + options, ); }), deployAFunction: ( input: typeof operationDefinitions.v1DeployAFunction.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeployAFunction.outputSchema.Type, SupabaseApiError, @@ -565,10 +643,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeployAFunction">( operationDefinitions.v1DeployAFunction, input, + options, ); }), diffABranch: ( input: typeof operationDefinitions.v1DiffABranch.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DiffABranch.outputSchema.Type, SupabaseApiError, @@ -576,10 +656,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1DiffABranch">(operationDefinitions.v1DiffABranch, input); + return yield* client.execute<"v1DiffABranch">( + operationDefinitions.v1DiffABranch, + input, + options, + ); }), disablePreviewBranching: ( input: typeof operationDefinitions.v1DisablePreviewBranching.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DisablePreviewBranching.outputSchema.Type, SupabaseApiError, @@ -590,10 +675,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DisablePreviewBranching">( operationDefinitions.v1DisablePreviewBranching, input, + options, ); }), disableReadonlyModeTemporarily: ( input: typeof operationDefinitions.v1DisableReadonlyModeTemporarily.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DisableReadonlyModeTemporarily.outputSchema.Type, SupabaseApiError, @@ -604,10 +691,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DisableReadonlyModeTemporarily">( operationDefinitions.v1DisableReadonlyModeTemporarily, input, + options, ); }), enableDatabaseWebhook: ( input: typeof operationDefinitions.v1EnableDatabaseWebhook.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1EnableDatabaseWebhook.outputSchema.Type, SupabaseApiError, @@ -618,10 +707,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1EnableDatabaseWebhook">( operationDefinitions.v1EnableDatabaseWebhook, input, + options, ); }), exchangeOauthToken: ( input: typeof operationDefinitions.v1ExchangeOauthToken.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ExchangeOauthToken.outputSchema.Type, SupabaseApiError, @@ -632,10 +723,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ExchangeOauthToken">( operationDefinitions.v1ExchangeOauthToken, input, + options, ); }), generateTypescriptTypes: ( input: typeof operationDefinitions.v1GenerateTypescriptTypes.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GenerateTypescriptTypes.outputSchema.Type, SupabaseApiError, @@ -646,10 +739,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GenerateTypescriptTypes">( operationDefinitions.v1GenerateTypescriptTypes, input, + options, ); }), getABranch: ( input: typeof operationDefinitions.v1GetABranch.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetABranch.outputSchema.Type, SupabaseApiError, @@ -657,10 +752,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1GetABranch">(operationDefinitions.v1GetABranch, input); + return yield* client.execute<"v1GetABranch">( + operationDefinitions.v1GetABranch, + input, + options, + ); }), getABranchConfig: ( input: typeof operationDefinitions.v1GetABranchConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetABranchConfig.outputSchema.Type, SupabaseApiError, @@ -671,10 +771,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetABranchConfig">( operationDefinitions.v1GetABranchConfig, input, + options, ); }), getAFunction: ( input: typeof operationDefinitions.v1GetAFunction.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetAFunction.outputSchema.Type, SupabaseApiError, @@ -682,10 +784,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1GetAFunction">(operationDefinitions.v1GetAFunction, input); + return yield* client.execute<"v1GetAFunction">( + operationDefinitions.v1GetAFunction, + input, + options, + ); }), getAFunctionBody: ( input: typeof operationDefinitions.v1GetAFunctionBody.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetAFunctionBody.outputSchema.Type, SupabaseApiError, @@ -696,10 +803,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetAFunctionBody">( operationDefinitions.v1GetAFunctionBody, input, + options, ); }), getAMigration: ( input: typeof operationDefinitions.v1GetAMigration.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetAMigration.outputSchema.Type, SupabaseApiError, @@ -710,10 +819,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetAMigration">( operationDefinitions.v1GetAMigration, input, + options, ); }), getASnippet: ( input: typeof operationDefinitions.v1GetASnippet.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetASnippet.outputSchema.Type, SupabaseApiError, @@ -721,10 +832,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1GetASnippet">(operationDefinitions.v1GetASnippet, input); + return yield* client.execute<"v1GetASnippet">( + operationDefinitions.v1GetASnippet, + input, + options, + ); }), getASsoProvider: ( input: typeof operationDefinitions.v1GetASsoProvider.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetASsoProvider.outputSchema.Type, SupabaseApiError, @@ -735,10 +851,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetASsoProvider">( operationDefinitions.v1GetASsoProvider, input, + options, ); }), getActionRun: ( input: typeof operationDefinitions.v1GetActionRun.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetActionRun.outputSchema.Type, SupabaseApiError, @@ -746,10 +864,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1GetActionRun">(operationDefinitions.v1GetActionRun, input); + return yield* client.execute<"v1GetActionRun">( + operationDefinitions.v1GetActionRun, + input, + options, + ); }), getActionRunLogs: ( input: typeof operationDefinitions.v1GetActionRunLogs.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetActionRunLogs.outputSchema.Type, SupabaseApiError, @@ -760,10 +883,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetActionRunLogs">( operationDefinitions.v1GetActionRunLogs, input, + options, ); }), getAllProjectsForOrganization: ( input: typeof operationDefinitions.v1GetAllProjectsForOrganization.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetAllProjectsForOrganization.outputSchema.Type, SupabaseApiError, @@ -774,10 +899,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetAllProjectsForOrganization">( operationDefinitions.v1GetAllProjectsForOrganization, input, + options, ); }), getAnOrganization: ( input: typeof operationDefinitions.v1GetAnOrganization.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetAnOrganization.outputSchema.Type, SupabaseApiError, @@ -788,10 +915,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetAnOrganization">( operationDefinitions.v1GetAnOrganization, input, + options, ); }), getAuthServiceConfig: ( input: typeof operationDefinitions.v1GetAuthServiceConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetAuthServiceConfig.outputSchema.Type, SupabaseApiError, @@ -802,10 +931,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetAuthServiceConfig">( operationDefinitions.v1GetAuthServiceConfig, input, + options, ); }), getAvailableRegions: ( input: typeof operationDefinitions.v1GetAvailableRegions.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetAvailableRegions.outputSchema.Type, SupabaseApiError, @@ -816,10 +947,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetAvailableRegions">( operationDefinitions.v1GetAvailableRegions, input, + options, ); }), getBackupSchedule: ( input: typeof operationDefinitions.v1GetBackupSchedule.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetBackupSchedule.outputSchema.Type, SupabaseApiError, @@ -830,10 +963,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetBackupSchedule">( operationDefinitions.v1GetBackupSchedule, input, + options, ); }), getDatabaseDisk: ( input: typeof operationDefinitions.v1GetDatabaseDisk.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetDatabaseDisk.outputSchema.Type, SupabaseApiError, @@ -844,10 +979,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetDatabaseDisk">( operationDefinitions.v1GetDatabaseDisk, input, + options, ); }), getDatabaseMetadata: ( input: typeof operationDefinitions.v1GetDatabaseMetadata.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetDatabaseMetadata.outputSchema.Type, SupabaseApiError, @@ -858,10 +995,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetDatabaseMetadata">( operationDefinitions.v1GetDatabaseMetadata, input, + options, ); }), getDatabaseOpenapi: ( input: typeof operationDefinitions.v1GetDatabaseOpenapi.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetDatabaseOpenapi.outputSchema.Type, SupabaseApiError, @@ -872,10 +1011,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetDatabaseOpenapi">( operationDefinitions.v1GetDatabaseOpenapi, input, + options, ); }), getDiskUtilization: ( input: typeof operationDefinitions.v1GetDiskUtilization.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetDiskUtilization.outputSchema.Type, SupabaseApiError, @@ -886,10 +1027,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetDiskUtilization">( operationDefinitions.v1GetDiskUtilization, input, + options, ); }), getHostnameConfig: ( input: typeof operationDefinitions.v1GetHostnameConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetHostnameConfig.outputSchema.Type, SupabaseApiError, @@ -900,10 +1043,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetHostnameConfig">( operationDefinitions.v1GetHostnameConfig, input, + options, ); }), getJitAccess: ( input: typeof operationDefinitions.v1GetJitAccess.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetJitAccess.outputSchema.Type, SupabaseApiError, @@ -911,10 +1056,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1GetJitAccess">(operationDefinitions.v1GetJitAccess, input); + return yield* client.execute<"v1GetJitAccess">( + operationDefinitions.v1GetJitAccess, + input, + options, + ); }), getJitAccessConfig: ( input: typeof operationDefinitions.v1GetJitAccessConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetJitAccessConfig.outputSchema.Type, SupabaseApiError, @@ -925,10 +1075,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetJitAccessConfig">( operationDefinitions.v1GetJitAccessConfig, input, + options, ); }), getLegacySigningKey: ( input: typeof operationDefinitions.v1GetLegacySigningKey.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetLegacySigningKey.outputSchema.Type, SupabaseApiError, @@ -939,10 +1091,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetLegacySigningKey">( operationDefinitions.v1GetLegacySigningKey, input, + options, ); }), getNetworkRestrictions: ( input: typeof operationDefinitions.v1GetNetworkRestrictions.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetNetworkRestrictions.outputSchema.Type, SupabaseApiError, @@ -953,10 +1107,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetNetworkRestrictions">( operationDefinitions.v1GetNetworkRestrictions, input, + options, ); }), getOrganizationEntitlements: ( input: typeof operationDefinitions.v1GetOrganizationEntitlements.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetOrganizationEntitlements.outputSchema.Type, SupabaseApiError, @@ -967,10 +1123,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetOrganizationEntitlements">( operationDefinitions.v1GetOrganizationEntitlements, input, + options, ); }), getOrganizationProjectClaim: ( input: typeof operationDefinitions.v1GetOrganizationProjectClaim.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetOrganizationProjectClaim.outputSchema.Type, SupabaseApiError, @@ -981,10 +1139,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetOrganizationProjectClaim">( operationDefinitions.v1GetOrganizationProjectClaim, input, + options, ); }), getPerformanceAdvisors: ( input: typeof operationDefinitions.v1GetPerformanceAdvisors.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetPerformanceAdvisors.outputSchema.Type, SupabaseApiError, @@ -995,10 +1155,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetPerformanceAdvisors">( operationDefinitions.v1GetPerformanceAdvisors, input, + options, ); }), getPgsodiumConfig: ( input: typeof operationDefinitions.v1GetPgsodiumConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetPgsodiumConfig.outputSchema.Type, SupabaseApiError, @@ -1009,10 +1171,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetPgsodiumConfig">( operationDefinitions.v1GetPgsodiumConfig, input, + options, ); }), getPoolerConfig: ( input: typeof operationDefinitions.v1GetPoolerConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetPoolerConfig.outputSchema.Type, SupabaseApiError, @@ -1023,10 +1187,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetPoolerConfig">( operationDefinitions.v1GetPoolerConfig, input, + options, ); }), getPostgresConfig: ( input: typeof operationDefinitions.v1GetPostgresConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetPostgresConfig.outputSchema.Type, SupabaseApiError, @@ -1037,10 +1203,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetPostgresConfig">( operationDefinitions.v1GetPostgresConfig, input, + options, ); }), getPostgresUpgradeEligibility: ( input: typeof operationDefinitions.v1GetPostgresUpgradeEligibility.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetPostgresUpgradeEligibility.outputSchema.Type, SupabaseApiError, @@ -1051,10 +1219,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetPostgresUpgradeEligibility">( operationDefinitions.v1GetPostgresUpgradeEligibility, input, + options, ); }), getPostgresUpgradeStatus: ( input: typeof operationDefinitions.v1GetPostgresUpgradeStatus.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetPostgresUpgradeStatus.outputSchema.Type, SupabaseApiError, @@ -1065,10 +1235,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetPostgresUpgradeStatus">( operationDefinitions.v1GetPostgresUpgradeStatus, input, + options, ); }), getPostgrestServiceConfig: ( input: typeof operationDefinitions.v1GetPostgrestServiceConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetPostgrestServiceConfig.outputSchema.Type, SupabaseApiError, @@ -1079,19 +1251,28 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetPostgrestServiceConfig">( operationDefinitions.v1GetPostgrestServiceConfig, input, + options, ); }), - getProfile: (): Effect.Effect< + getProfile: ( + _input: Record = {}, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< typeof operationDefinitions.v1GetProfile.outputSchema.Type, SupabaseApiError, SupabaseApiClient > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1GetProfile">(operationDefinitions.v1GetProfile, {}); + return yield* client.execute<"v1GetProfile">( + operationDefinitions.v1GetProfile, + {}, + options, + ); }), getProject: ( input: typeof operationDefinitions.v1GetProject.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProject.outputSchema.Type, SupabaseApiError, @@ -1099,10 +1280,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1GetProject">(operationDefinitions.v1GetProject, input); + return yield* client.execute<"v1GetProject">( + operationDefinitions.v1GetProject, + input, + options, + ); }), getProjectApiKey: ( input: typeof operationDefinitions.v1GetProjectApiKey.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectApiKey.outputSchema.Type, SupabaseApiError, @@ -1113,10 +1299,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectApiKey">( operationDefinitions.v1GetProjectApiKey, input, + options, ); }), getProjectApiKeys: ( input: typeof operationDefinitions.v1GetProjectApiKeys.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectApiKeys.outputSchema.Type, SupabaseApiError, @@ -1127,10 +1315,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectApiKeys">( operationDefinitions.v1GetProjectApiKeys, input, + options, ); }), getProjectClaimToken: ( input: typeof operationDefinitions.v1GetProjectClaimToken.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectClaimToken.outputSchema.Type, SupabaseApiError, @@ -1141,10 +1331,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectClaimToken">( operationDefinitions.v1GetProjectClaimToken, input, + options, ); }), getProjectDiskAutoscaleConfig: ( input: typeof operationDefinitions.v1GetProjectDiskAutoscaleConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectDiskAutoscaleConfig.outputSchema.Type, SupabaseApiError, @@ -1155,10 +1347,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectDiskAutoscaleConfig">( operationDefinitions.v1GetProjectDiskAutoscaleConfig, input, + options, ); }), getProjectFunctionCombinedStats: ( input: typeof operationDefinitions.v1GetProjectFunctionCombinedStats.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectFunctionCombinedStats.outputSchema.Type, SupabaseApiError, @@ -1169,10 +1363,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectFunctionCombinedStats">( operationDefinitions.v1GetProjectFunctionCombinedStats, input, + options, ); }), getProjectLegacyApiKeys: ( input: typeof operationDefinitions.v1GetProjectLegacyApiKeys.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectLegacyApiKeys.outputSchema.Type, SupabaseApiError, @@ -1183,10 +1379,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectLegacyApiKeys">( operationDefinitions.v1GetProjectLegacyApiKeys, input, + options, ); }), getProjectLogs: ( input: typeof operationDefinitions.v1GetProjectLogs.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectLogs.outputSchema.Type, SupabaseApiError, @@ -1197,10 +1395,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectLogs">( operationDefinitions.v1GetProjectLogs, input, + options, ); }), getProjectLogsAll: ( input: typeof operationDefinitions.v1GetProjectLogsAll.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectLogsAll.outputSchema.Type, SupabaseApiError, @@ -1211,10 +1411,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectLogsAll">( operationDefinitions.v1GetProjectLogsAll, input, + options, ); }), getProjectPgbouncerConfig: ( input: typeof operationDefinitions.v1GetProjectPgbouncerConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectPgbouncerConfig.outputSchema.Type, SupabaseApiError, @@ -1225,10 +1427,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectPgbouncerConfig">( operationDefinitions.v1GetProjectPgbouncerConfig, input, + options, ); }), getProjectSigningKey: ( input: typeof operationDefinitions.v1GetProjectSigningKey.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectSigningKey.outputSchema.Type, SupabaseApiError, @@ -1239,10 +1443,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectSigningKey">( operationDefinitions.v1GetProjectSigningKey, input, + options, ); }), getProjectSigningKeys: ( input: typeof operationDefinitions.v1GetProjectSigningKeys.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectSigningKeys.outputSchema.Type, SupabaseApiError, @@ -1253,10 +1459,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectSigningKeys">( operationDefinitions.v1GetProjectSigningKeys, input, + options, ); }), getProjectTpaIntegration: ( input: typeof operationDefinitions.v1GetProjectTpaIntegration.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectTpaIntegration.outputSchema.Type, SupabaseApiError, @@ -1267,10 +1475,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectTpaIntegration">( operationDefinitions.v1GetProjectTpaIntegration, input, + options, ); }), getProjectUsageApiCount: ( input: typeof operationDefinitions.v1GetProjectUsageApiCount.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectUsageApiCount.outputSchema.Type, SupabaseApiError, @@ -1281,10 +1491,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectUsageApiCount">( operationDefinitions.v1GetProjectUsageApiCount, input, + options, ); }), getProjectUsageRequestCount: ( input: typeof operationDefinitions.v1GetProjectUsageRequestCount.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectUsageRequestCount.outputSchema.Type, SupabaseApiError, @@ -1295,10 +1507,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectUsageRequestCount">( operationDefinitions.v1GetProjectUsageRequestCount, input, + options, ); }), getReadonlyModeStatus: ( input: typeof operationDefinitions.v1GetReadonlyModeStatus.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetReadonlyModeStatus.outputSchema.Type, SupabaseApiError, @@ -1309,10 +1523,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetReadonlyModeStatus">( operationDefinitions.v1GetReadonlyModeStatus, input, + options, ); }), getRealtimeConfig: ( input: typeof operationDefinitions.v1GetRealtimeConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetRealtimeConfig.outputSchema.Type, SupabaseApiError, @@ -1323,10 +1539,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetRealtimeConfig">( operationDefinitions.v1GetRealtimeConfig, input, + options, ); }), getRestorePoint: ( input: typeof operationDefinitions.v1GetRestorePoint.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetRestorePoint.outputSchema.Type, SupabaseApiError, @@ -1337,10 +1555,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetRestorePoint">( operationDefinitions.v1GetRestorePoint, input, + options, ); }), getSecurityAdvisors: ( input: typeof operationDefinitions.v1GetSecurityAdvisors.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetSecurityAdvisors.outputSchema.Type, SupabaseApiError, @@ -1351,10 +1571,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetSecurityAdvisors">( operationDefinitions.v1GetSecurityAdvisors, input, + options, ); }), getServicesHealth: ( input: typeof operationDefinitions.v1GetServicesHealth.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetServicesHealth.outputSchema.Type, SupabaseApiError, @@ -1365,10 +1587,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetServicesHealth">( operationDefinitions.v1GetServicesHealth, input, + options, ); }), getSslEnforcementConfig: ( input: typeof operationDefinitions.v1GetSslEnforcementConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetSslEnforcementConfig.outputSchema.Type, SupabaseApiError, @@ -1379,10 +1603,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetSslEnforcementConfig">( operationDefinitions.v1GetSslEnforcementConfig, input, + options, ); }), getStorageConfig: ( input: typeof operationDefinitions.v1GetStorageConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetStorageConfig.outputSchema.Type, SupabaseApiError, @@ -1393,10 +1619,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetStorageConfig">( operationDefinitions.v1GetStorageConfig, input, + options, ); }), getVanitySubdomainConfig: ( input: typeof operationDefinitions.v1GetVanitySubdomainConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetVanitySubdomainConfig.outputSchema.Type, SupabaseApiError, @@ -1407,10 +1635,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetVanitySubdomainConfig">( operationDefinitions.v1GetVanitySubdomainConfig, input, + options, ); }), inviteExternalJitAccess: ( input: typeof operationDefinitions.v1InviteExternalJitAccess.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1InviteExternalJitAccess.outputSchema.Type, SupabaseApiError, @@ -1421,10 +1651,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1InviteExternalJitAccess">( operationDefinitions.v1InviteExternalJitAccess, input, + options, ); }), listActionRuns: ( input: typeof operationDefinitions.v1ListActionRuns.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListActionRuns.outputSchema.Type, SupabaseApiError, @@ -1435,10 +1667,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListActionRuns">( operationDefinitions.v1ListActionRuns, input, + options, ); }), listAllBackups: ( input: typeof operationDefinitions.v1ListAllBackups.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListAllBackups.outputSchema.Type, SupabaseApiError, @@ -1449,10 +1683,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllBackups">( operationDefinitions.v1ListAllBackups, input, + options, ); }), listAllBranches: ( input: typeof operationDefinitions.v1ListAllBranches.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListAllBranches.outputSchema.Type, SupabaseApiError, @@ -1463,10 +1699,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllBranches">( operationDefinitions.v1ListAllBranches, input, + options, ); }), listAllBuckets: ( input: typeof operationDefinitions.v1ListAllBuckets.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListAllBuckets.outputSchema.Type, SupabaseApiError, @@ -1477,10 +1715,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllBuckets">( operationDefinitions.v1ListAllBuckets, input, + options, ); }), listAllFunctions: ( input: typeof operationDefinitions.v1ListAllFunctions.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListAllFunctions.outputSchema.Type, SupabaseApiError, @@ -1491,10 +1731,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllFunctions">( operationDefinitions.v1ListAllFunctions, input, + options, ); }), listAllNetworkBans: ( input: typeof operationDefinitions.v1ListAllNetworkBans.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListAllNetworkBans.outputSchema.Type, SupabaseApiError, @@ -1505,10 +1747,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllNetworkBans">( operationDefinitions.v1ListAllNetworkBans, input, + options, ); }), listAllNetworkBansEnriched: ( input: typeof operationDefinitions.v1ListAllNetworkBansEnriched.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListAllNetworkBansEnriched.outputSchema.Type, SupabaseApiError, @@ -1519,9 +1763,13 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllNetworkBansEnriched">( operationDefinitions.v1ListAllNetworkBansEnriched, input, + options, ); }), - listAllOrganizations: (): Effect.Effect< + listAllOrganizations: ( + _input: Record = {}, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< typeof operationDefinitions.v1ListAllOrganizations.outputSchema.Type, SupabaseApiError, SupabaseApiClient @@ -1531,9 +1779,13 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllOrganizations">( operationDefinitions.v1ListAllOrganizations, {}, + options, ); }), - listAllProjects: (): Effect.Effect< + listAllProjects: ( + _input: Record = {}, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< typeof operationDefinitions.v1ListAllProjects.outputSchema.Type, SupabaseApiError, SupabaseApiClient @@ -1543,10 +1795,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllProjects">( operationDefinitions.v1ListAllProjects, {}, + options, ); }), listAllSecrets: ( input: typeof operationDefinitions.v1ListAllSecrets.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListAllSecrets.outputSchema.Type, SupabaseApiError, @@ -1557,10 +1811,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllSecrets">( operationDefinitions.v1ListAllSecrets, input, + options, ); }), listAllSnippets: ( input: typeof operationDefinitions.v1ListAllSnippets.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListAllSnippets.outputSchema.Type, SupabaseApiError, @@ -1571,10 +1827,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllSnippets">( operationDefinitions.v1ListAllSnippets, input, + options, ); }), listAllSsoProvider: ( input: typeof operationDefinitions.v1ListAllSsoProvider.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListAllSsoProvider.outputSchema.Type, SupabaseApiError, @@ -1585,10 +1843,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllSsoProvider">( operationDefinitions.v1ListAllSsoProvider, input, + options, ); }), listAvailableRestoreVersions: ( input: typeof operationDefinitions.v1ListAvailableRestoreVersions.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListAvailableRestoreVersions.outputSchema.Type, SupabaseApiError, @@ -1599,10 +1859,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAvailableRestoreVersions">( operationDefinitions.v1ListAvailableRestoreVersions, input, + options, ); }), listJitAccess: ( input: typeof operationDefinitions.v1ListJitAccess.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListJitAccess.outputSchema.Type, SupabaseApiError, @@ -1613,10 +1875,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListJitAccess">( operationDefinitions.v1ListJitAccess, input, + options, ); }), listMigrationHistory: ( input: typeof operationDefinitions.v1ListMigrationHistory.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListMigrationHistory.outputSchema.Type, SupabaseApiError, @@ -1627,10 +1891,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListMigrationHistory">( operationDefinitions.v1ListMigrationHistory, input, + options, ); }), listOrganizationMembers: ( input: typeof operationDefinitions.v1ListOrganizationMembers.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListOrganizationMembers.outputSchema.Type, SupabaseApiError, @@ -1641,10 +1907,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListOrganizationMembers">( operationDefinitions.v1ListOrganizationMembers, input, + options, ); }), listProjectAddons: ( input: typeof operationDefinitions.v1ListProjectAddons.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListProjectAddons.outputSchema.Type, SupabaseApiError, @@ -1655,10 +1923,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListProjectAddons">( operationDefinitions.v1ListProjectAddons, input, + options, ); }), listProjectTpaIntegrations: ( input: typeof operationDefinitions.v1ListProjectTpaIntegrations.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListProjectTpaIntegrations.outputSchema.Type, SupabaseApiError, @@ -1669,10 +1939,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListProjectTpaIntegrations">( operationDefinitions.v1ListProjectTpaIntegrations, input, + options, ); }), mergeABranch: ( input: typeof operationDefinitions.v1MergeABranch.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1MergeABranch.outputSchema.Type, SupabaseApiError, @@ -1680,10 +1952,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1MergeABranch">(operationDefinitions.v1MergeABranch, input); + return yield* client.execute<"v1MergeABranch">( + operationDefinitions.v1MergeABranch, + input, + options, + ); }), modifyDatabaseDisk: ( input: typeof operationDefinitions.v1ModifyDatabaseDisk.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ModifyDatabaseDisk.outputSchema.Type, SupabaseApiError, @@ -1694,10 +1971,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ModifyDatabaseDisk">( operationDefinitions.v1ModifyDatabaseDisk, input, + options, ); }), oauthAuthorizeProjectClaim: ( input: typeof operationDefinitions.v1OauthAuthorizeProjectClaim.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1OauthAuthorizeProjectClaim.outputSchema.Type, SupabaseApiError, @@ -1708,10 +1987,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1OauthAuthorizeProjectClaim">( operationDefinitions.v1OauthAuthorizeProjectClaim, input, + options, ); }), patchAMigration: ( input: typeof operationDefinitions.v1PatchAMigration.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1PatchAMigration.outputSchema.Type, SupabaseApiError, @@ -1722,10 +2003,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1PatchAMigration">( operationDefinitions.v1PatchAMigration, input, + options, ); }), patchNetworkRestrictions: ( input: typeof operationDefinitions.v1PatchNetworkRestrictions.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1PatchNetworkRestrictions.outputSchema.Type, SupabaseApiError, @@ -1736,10 +2019,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1PatchNetworkRestrictions">( operationDefinitions.v1PatchNetworkRestrictions, input, + options, ); }), pauseAProject: ( input: typeof operationDefinitions.v1PauseAProject.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1PauseAProject.outputSchema.Type, SupabaseApiError, @@ -1750,10 +2035,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1PauseAProject">( operationDefinitions.v1PauseAProject, input, + options, ); }), pushABranch: ( input: typeof operationDefinitions.v1PushABranch.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1PushABranch.outputSchema.Type, SupabaseApiError, @@ -1761,10 +2048,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1PushABranch">(operationDefinitions.v1PushABranch, input); + return yield* client.execute<"v1PushABranch">( + operationDefinitions.v1PushABranch, + input, + options, + ); }), readOnlyQuery: ( input: typeof operationDefinitions.v1ReadOnlyQuery.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ReadOnlyQuery.outputSchema.Type, SupabaseApiError, @@ -1775,10 +2067,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ReadOnlyQuery">( operationDefinitions.v1ReadOnlyQuery, input, + options, ); }), removeAReadReplica: ( input: typeof operationDefinitions.v1RemoveAReadReplica.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RemoveAReadReplica.outputSchema.Type, SupabaseApiError, @@ -1789,10 +2083,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1RemoveAReadReplica">( operationDefinitions.v1RemoveAReadReplica, input, + options, ); }), removeProjectAddon: ( input: typeof operationDefinitions.v1RemoveProjectAddon.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RemoveProjectAddon.outputSchema.Type, SupabaseApiError, @@ -1803,10 +2099,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1RemoveProjectAddon">( operationDefinitions.v1RemoveProjectAddon, input, + options, ); }), removeProjectSigningKey: ( input: typeof operationDefinitions.v1RemoveProjectSigningKey.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RemoveProjectSigningKey.outputSchema.Type, SupabaseApiError, @@ -1817,10 +2115,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1RemoveProjectSigningKey">( operationDefinitions.v1RemoveProjectSigningKey, input, + options, ); }), resetABranch: ( input: typeof operationDefinitions.v1ResetABranch.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ResetABranch.outputSchema.Type, SupabaseApiError, @@ -1828,10 +2128,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1ResetABranch">(operationDefinitions.v1ResetABranch, input); + return yield* client.execute<"v1ResetABranch">( + operationDefinitions.v1ResetABranch, + input, + options, + ); }), restartAProject: ( input: typeof operationDefinitions.v1RestartAProject.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RestartAProject.outputSchema.Type, SupabaseApiError, @@ -1842,10 +2147,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1RestartAProject">( operationDefinitions.v1RestartAProject, input, + options, ); }), restoreABranch: ( input: typeof operationDefinitions.v1RestoreABranch.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RestoreABranch.outputSchema.Type, SupabaseApiError, @@ -1856,10 +2163,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1RestoreABranch">( operationDefinitions.v1RestoreABranch, input, + options, ); }), restoreAProject: ( input: typeof operationDefinitions.v1RestoreAProject.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RestoreAProject.outputSchema.Type, SupabaseApiError, @@ -1870,10 +2179,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1RestoreAProject">( operationDefinitions.v1RestoreAProject, input, + options, ); }), restorePhysicalBackup: ( input: typeof operationDefinitions.v1RestorePhysicalBackup.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RestorePhysicalBackup.outputSchema.Type, SupabaseApiError, @@ -1884,10 +2195,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1RestorePhysicalBackup">( operationDefinitions.v1RestorePhysicalBackup, input, + options, ); }), restorePitrBackup: ( input: typeof operationDefinitions.v1RestorePitrBackup.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RestorePitrBackup.outputSchema.Type, SupabaseApiError, @@ -1898,10 +2211,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1RestorePitrBackup">( operationDefinitions.v1RestorePitrBackup, input, + options, ); }), revokeToken: ( input: typeof operationDefinitions.v1RevokeToken.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RevokeToken.outputSchema.Type, SupabaseApiError, @@ -1909,10 +2224,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1RevokeToken">(operationDefinitions.v1RevokeToken, input); + return yield* client.execute<"v1RevokeToken">( + operationDefinitions.v1RevokeToken, + input, + options, + ); }), rollbackMigrations: ( input: typeof operationDefinitions.v1RollbackMigrations.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RollbackMigrations.outputSchema.Type, SupabaseApiError, @@ -1923,10 +2243,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1RollbackMigrations">( operationDefinitions.v1RollbackMigrations, input, + options, ); }), runAQuery: ( input: typeof operationDefinitions.v1RunAQuery.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RunAQuery.outputSchema.Type, SupabaseApiError, @@ -1934,10 +2256,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1RunAQuery">(operationDefinitions.v1RunAQuery, input); + return yield* client.execute<"v1RunAQuery">( + operationDefinitions.v1RunAQuery, + input, + options, + ); }), scrapeProjectMetrics: ( input: typeof operationDefinitions.v1ScrapeProjectMetrics.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ScrapeProjectMetrics.outputSchema.Type, SupabaseApiError, @@ -1948,10 +2275,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ScrapeProjectMetrics">( operationDefinitions.v1ScrapeProjectMetrics, input, + options, ); }), setupAReadReplica: ( input: typeof operationDefinitions.v1SetupAReadReplica.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1SetupAReadReplica.outputSchema.Type, SupabaseApiError, @@ -1962,10 +2291,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1SetupAReadReplica">( operationDefinitions.v1SetupAReadReplica, input, + options, ); }), shutdownRealtime: ( input: typeof operationDefinitions.v1ShutdownRealtime.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ShutdownRealtime.outputSchema.Type, SupabaseApiError, @@ -1976,10 +2307,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ShutdownRealtime">( operationDefinitions.v1ShutdownRealtime, input, + options, ); }), undo: ( input: typeof operationDefinitions.v1Undo.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1Undo.outputSchema.Type, SupabaseApiError, @@ -1987,10 +2320,11 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1Undo">(operationDefinitions.v1Undo, input); + return yield* client.execute<"v1Undo">(operationDefinitions.v1Undo, input, options); }), updateABranchConfig: ( input: typeof operationDefinitions.v1UpdateABranchConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateABranchConfig.outputSchema.Type, SupabaseApiError, @@ -2001,10 +2335,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateABranchConfig">( operationDefinitions.v1UpdateABranchConfig, input, + options, ); }), updateAFunction: ( input: typeof operationDefinitions.v1UpdateAFunction.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateAFunction.outputSchema.Type, SupabaseApiError, @@ -2015,10 +2351,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateAFunction">( operationDefinitions.v1UpdateAFunction, input, + options, ); }), updateAProject: ( input: typeof operationDefinitions.v1UpdateAProject.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateAProject.outputSchema.Type, SupabaseApiError, @@ -2029,10 +2367,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateAProject">( operationDefinitions.v1UpdateAProject, input, + options, ); }), updateASsoProvider: ( input: typeof operationDefinitions.v1UpdateASsoProvider.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateASsoProvider.outputSchema.Type, SupabaseApiError, @@ -2043,10 +2383,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateASsoProvider">( operationDefinitions.v1UpdateASsoProvider, input, + options, ); }), updateActionRunStatus: ( input: typeof operationDefinitions.v1UpdateActionRunStatus.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateActionRunStatus.outputSchema.Type, SupabaseApiError, @@ -2057,10 +2399,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateActionRunStatus">( operationDefinitions.v1UpdateActionRunStatus, input, + options, ); }), updateAuthServiceConfig: ( input: typeof operationDefinitions.v1UpdateAuthServiceConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateAuthServiceConfig.outputSchema.Type, SupabaseApiError, @@ -2071,10 +2415,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateAuthServiceConfig">( operationDefinitions.v1UpdateAuthServiceConfig, input, + options, ); }), updateBackupSchedule: ( input: typeof operationDefinitions.v1UpdateBackupSchedule.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateBackupSchedule.outputSchema.Type, SupabaseApiError, @@ -2085,10 +2431,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateBackupSchedule">( operationDefinitions.v1UpdateBackupSchedule, input, + options, ); }), updateDatabasePassword: ( input: typeof operationDefinitions.v1UpdateDatabasePassword.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateDatabasePassword.outputSchema.Type, SupabaseApiError, @@ -2099,10 +2447,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateDatabasePassword">( operationDefinitions.v1UpdateDatabasePassword, input, + options, ); }), updateHostnameConfig: ( input: typeof operationDefinitions.v1UpdateHostnameConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateHostnameConfig.outputSchema.Type, SupabaseApiError, @@ -2113,10 +2463,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateHostnameConfig">( operationDefinitions.v1UpdateHostnameConfig, input, + options, ); }), updateJitAccess: ( input: typeof operationDefinitions.v1UpdateJitAccess.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateJitAccess.outputSchema.Type, SupabaseApiError, @@ -2127,10 +2479,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateJitAccess">( operationDefinitions.v1UpdateJitAccess, input, + options, ); }), updateJitAccessConfig: ( input: typeof operationDefinitions.v1UpdateJitAccessConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateJitAccessConfig.outputSchema.Type, SupabaseApiError, @@ -2141,10 +2495,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateJitAccessConfig">( operationDefinitions.v1UpdateJitAccessConfig, input, + options, ); }), updateNetworkRestrictions: ( input: typeof operationDefinitions.v1UpdateNetworkRestrictions.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateNetworkRestrictions.outputSchema.Type, SupabaseApiError, @@ -2155,10 +2511,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateNetworkRestrictions">( operationDefinitions.v1UpdateNetworkRestrictions, input, + options, ); }), updatePgsodiumConfig: ( input: typeof operationDefinitions.v1UpdatePgsodiumConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdatePgsodiumConfig.outputSchema.Type, SupabaseApiError, @@ -2169,10 +2527,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdatePgsodiumConfig">( operationDefinitions.v1UpdatePgsodiumConfig, input, + options, ); }), updatePoolerConfig: ( input: typeof operationDefinitions.v1UpdatePoolerConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdatePoolerConfig.outputSchema.Type, SupabaseApiError, @@ -2183,10 +2543,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdatePoolerConfig">( operationDefinitions.v1UpdatePoolerConfig, input, + options, ); }), updatePostgresConfig: ( input: typeof operationDefinitions.v1UpdatePostgresConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdatePostgresConfig.outputSchema.Type, SupabaseApiError, @@ -2197,10 +2559,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdatePostgresConfig">( operationDefinitions.v1UpdatePostgresConfig, input, + options, ); }), updatePostgrestServiceConfig: ( input: typeof operationDefinitions.v1UpdatePostgrestServiceConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdatePostgrestServiceConfig.outputSchema.Type, SupabaseApiError, @@ -2211,10 +2575,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdatePostgrestServiceConfig">( operationDefinitions.v1UpdatePostgrestServiceConfig, input, + options, ); }), updateProjectApiKey: ( input: typeof operationDefinitions.v1UpdateProjectApiKey.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateProjectApiKey.outputSchema.Type, SupabaseApiError, @@ -2225,10 +2591,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateProjectApiKey">( operationDefinitions.v1UpdateProjectApiKey, input, + options, ); }), updateProjectLegacyApiKeys: ( input: typeof operationDefinitions.v1UpdateProjectLegacyApiKeys.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateProjectLegacyApiKeys.outputSchema.Type, SupabaseApiError, @@ -2239,10 +2607,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateProjectLegacyApiKeys">( operationDefinitions.v1UpdateProjectLegacyApiKeys, input, + options, ); }), updateProjectSigningKey: ( input: typeof operationDefinitions.v1UpdateProjectSigningKey.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateProjectSigningKey.outputSchema.Type, SupabaseApiError, @@ -2253,10 +2623,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateProjectSigningKey">( operationDefinitions.v1UpdateProjectSigningKey, input, + options, ); }), updateRealtimeConfig: ( input: typeof operationDefinitions.v1UpdateRealtimeConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateRealtimeConfig.outputSchema.Type, SupabaseApiError, @@ -2267,10 +2639,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateRealtimeConfig">( operationDefinitions.v1UpdateRealtimeConfig, input, + options, ); }), updateSslEnforcementConfig: ( input: typeof operationDefinitions.v1UpdateSslEnforcementConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateSslEnforcementConfig.outputSchema.Type, SupabaseApiError, @@ -2281,10 +2655,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateSslEnforcementConfig">( operationDefinitions.v1UpdateSslEnforcementConfig, input, + options, ); }), updateStorageConfig: ( input: typeof operationDefinitions.v1UpdateStorageConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateStorageConfig.outputSchema.Type, SupabaseApiError, @@ -2295,10 +2671,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateStorageConfig">( operationDefinitions.v1UpdateStorageConfig, input, + options, ); }), upgradePostgresVersion: ( input: typeof operationDefinitions.v1UpgradePostgresVersion.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpgradePostgresVersion.outputSchema.Type, SupabaseApiError, @@ -2309,10 +2687,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpgradePostgresVersion">( operationDefinitions.v1UpgradePostgresVersion, input, + options, ); }), upsertAMigration: ( input: typeof operationDefinitions.v1UpsertAMigration.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpsertAMigration.outputSchema.Type, SupabaseApiError, @@ -2323,10 +2703,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpsertAMigration">( operationDefinitions.v1UpsertAMigration, input, + options, ); }), verifyDnsConfig: ( input: typeof operationDefinitions.v1VerifyDnsConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1VerifyDnsConfig.outputSchema.Type, SupabaseApiError, @@ -2337,12 +2719,14 @@ export const versionedEffectOperations = { return yield* client.execute<"v1VerifyDnsConfig">( operationDefinitions.v1VerifyDnsConfig, input, + options, ); }), }, v2: { assignOrganizationMemberRole: ( input: typeof operationDefinitions.v2AssignOrganizationMemberRole.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2AssignOrganizationMemberRole.outputSchema.Type, SupabaseApiError, @@ -2353,10 +2737,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2AssignOrganizationMemberRole">( operationDefinitions.v2AssignOrganizationMemberRole, input, + options, ); }), createABranch: ( input: typeof operationDefinitions.v2CreateABranch.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2CreateABranch.outputSchema.Type, SupabaseApiError, @@ -2367,10 +2753,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2CreateABranch">( operationDefinitions.v2CreateABranch, input, + options, ); }), createComputeInstanceUpload: ( input: typeof operationDefinitions.v2CreateComputeInstanceUpload.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2CreateComputeInstanceUpload.outputSchema.Type, SupabaseApiError, @@ -2381,10 +2769,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2CreateComputeInstanceUpload">( operationDefinitions.v2CreateComputeInstanceUpload, input, + options, ); }), createLogDrain: ( input: typeof operationDefinitions.v2CreateLogDrain.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2CreateLogDrain.outputSchema.Type, SupabaseApiError, @@ -2395,10 +2785,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2CreateLogDrain">( operationDefinitions.v2CreateLogDrain, input, + options, ); }), createNotebook: ( input: typeof operationDefinitions.v2CreateNotebook.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2CreateNotebook.outputSchema.Type, SupabaseApiError, @@ -2409,10 +2801,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2CreateNotebook">( operationDefinitions.v2CreateNotebook, input, + options, ); }), createOrganizationInvitations: ( input: typeof operationDefinitions.v2CreateOrganizationInvitations.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2CreateOrganizationInvitations.outputSchema.Type, SupabaseApiError, @@ -2423,10 +2817,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2CreateOrganizationInvitations">( operationDefinitions.v2CreateOrganizationInvitations, input, + options, ); }), createPrivateLinkAssociation: ( input: typeof operationDefinitions.v2CreatePrivateLinkAssociation.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2CreatePrivateLinkAssociation.outputSchema.Type, SupabaseApiError, @@ -2437,10 +2833,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2CreatePrivateLinkAssociation">( operationDefinitions.v2CreatePrivateLinkAssociation, input, + options, ); }), deleteAComputeInstance: ( input: typeof operationDefinitions.v2DeleteAComputeInstance.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2DeleteAComputeInstance.outputSchema.Type, SupabaseApiError, @@ -2451,10 +2849,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2DeleteAComputeInstance">( operationDefinitions.v2DeleteAComputeInstance, input, + options, ); }), deleteLogDrain: ( input: typeof operationDefinitions.v2DeleteLogDrain.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2DeleteLogDrain.outputSchema.Type, SupabaseApiError, @@ -2465,10 +2865,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2DeleteLogDrain">( operationDefinitions.v2DeleteLogDrain, input, + options, ); }), deleteNotebook: ( input: typeof operationDefinitions.v2DeleteNotebook.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2DeleteNotebook.outputSchema.Type, SupabaseApiError, @@ -2479,10 +2881,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2DeleteNotebook">( operationDefinitions.v2DeleteNotebook, input, + options, ); }), deleteOrganizationInvitations: ( input: typeof operationDefinitions.v2DeleteOrganizationInvitations.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2DeleteOrganizationInvitations.outputSchema.Type, SupabaseApiError, @@ -2493,10 +2897,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2DeleteOrganizationInvitations">( operationDefinitions.v2DeleteOrganizationInvitations, input, + options, ); }), deletePrivateLinkAssociation: ( input: typeof operationDefinitions.v2DeletePrivateLinkAssociation.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2DeletePrivateLinkAssociation.outputSchema.Type, SupabaseApiError, @@ -2507,10 +2913,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2DeletePrivateLinkAssociation">( operationDefinitions.v2DeletePrivateLinkAssociation, input, + options, ); }), deletePrivateLinkAssociationForDatabase: ( input: typeof operationDefinitions.v2DeletePrivateLinkAssociationForDatabase.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2DeletePrivateLinkAssociationForDatabase.outputSchema.Type, SupabaseApiError, @@ -2521,10 +2929,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2DeletePrivateLinkAssociationForDatabase">( operationDefinitions.v2DeletePrivateLinkAssociationForDatabase, input, + options, ); }), deployAComputeInstance: ( input: typeof operationDefinitions.v2DeployAComputeInstance.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2DeployAComputeInstance.outputSchema.Type, SupabaseApiError, @@ -2535,10 +2945,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2DeployAComputeInstance">( operationDefinitions.v2DeployAComputeInstance, input, + options, ); }), getAComputeInstance: ( input: typeof operationDefinitions.v2GetAComputeInstance.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2GetAComputeInstance.outputSchema.Type, SupabaseApiError, @@ -2549,10 +2961,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2GetAComputeInstance">( operationDefinitions.v2GetAComputeInstance, input, + options, ); }), getNotebook: ( input: typeof operationDefinitions.v2GetNotebook.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2GetNotebook.outputSchema.Type, SupabaseApiError, @@ -2560,10 +2974,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v2GetNotebook">(operationDefinitions.v2GetNotebook, input); + return yield* client.execute<"v2GetNotebook">( + operationDefinitions.v2GetNotebook, + input, + options, + ); }), getProjectConfig: ( input: typeof operationDefinitions.v2GetProjectConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2GetProjectConfig.outputSchema.Type, SupabaseApiError, @@ -2574,10 +2993,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2GetProjectConfig">( operationDefinitions.v2GetProjectConfig, input, + options, ); }), listAllComputeInstances: ( input: typeof operationDefinitions.v2ListAllComputeInstances.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2ListAllComputeInstances.outputSchema.Type, SupabaseApiError, @@ -2588,10 +3009,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2ListAllComputeInstances">( operationDefinitions.v2ListAllComputeInstances, input, + options, ); }), listLogDrains: ( input: typeof operationDefinitions.v2ListLogDrains.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2ListLogDrains.outputSchema.Type, SupabaseApiError, @@ -2602,10 +3025,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2ListLogDrains">( operationDefinitions.v2ListLogDrains, input, + options, ); }), listNotebooks: ( input: typeof operationDefinitions.v2ListNotebooks.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2ListNotebooks.outputSchema.Type, SupabaseApiError, @@ -2616,10 +3041,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2ListNotebooks">( operationDefinitions.v2ListNotebooks, input, + options, ); }), listOrganizationGithubConnections: ( input: typeof operationDefinitions.v2ListOrganizationGithubConnections.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2ListOrganizationGithubConnections.outputSchema.Type, SupabaseApiError, @@ -2630,10 +3057,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2ListOrganizationGithubConnections">( operationDefinitions.v2ListOrganizationGithubConnections, input, + options, ); }), listOrganizationMembers: ( input: typeof operationDefinitions.v2ListOrganizationMembers.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2ListOrganizationMembers.outputSchema.Type, SupabaseApiError, @@ -2644,10 +3073,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2ListOrganizationMembers">( operationDefinitions.v2ListOrganizationMembers, input, + options, ); }), listOrganizationProjects: ( input: typeof operationDefinitions.v2ListOrganizationProjects.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2ListOrganizationProjects.outputSchema.Type, SupabaseApiError, @@ -2658,10 +3089,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2ListOrganizationProjects">( operationDefinitions.v2ListOrganizationProjects, input, + options, ); }), listOrganizationRoles: ( input: typeof operationDefinitions.v2ListOrganizationRoles.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2ListOrganizationRoles.outputSchema.Type, SupabaseApiError, @@ -2672,10 +3105,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2ListOrganizationRoles">( operationDefinitions.v2ListOrganizationRoles, input, + options, ); }), listPrivateLinkAssociations: ( input: typeof operationDefinitions.v2ListPrivateLinkAssociations.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2ListPrivateLinkAssociations.outputSchema.Type, SupabaseApiError, @@ -2686,10 +3121,172 @@ export const versionedEffectOperations = { return yield* client.execute<"v2ListPrivateLinkAssociations">( operationDefinitions.v2ListPrivateLinkAssociations, input, + options, + ); + }), + organizationsSlugWebhooksDeliveriesIdGet: ( + input: typeof operationDefinitions.v2OrganizationsSlugWebhooksDeliveriesIdGet.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2OrganizationsSlugWebhooksDeliveriesIdGet.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2OrganizationsSlugWebhooksDeliveriesIdGet">( + operationDefinitions.v2OrganizationsSlugWebhooksDeliveriesIdGet, + input, + options, + ); + }), + organizationsSlugWebhooksDeliveriesIdRetryPost: ( + input: typeof operationDefinitions.v2OrganizationsSlugWebhooksDeliveriesIdRetryPost.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2OrganizationsSlugWebhooksDeliveriesIdRetryPost.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2OrganizationsSlugWebhooksDeliveriesIdRetryPost">( + operationDefinitions.v2OrganizationsSlugWebhooksDeliveriesIdRetryPost, + input, + options, + ); + }), + organizationsSlugWebhooksEndpointsDelete: ( + input: typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsDelete.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsDelete.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2OrganizationsSlugWebhooksEndpointsDelete">( + operationDefinitions.v2OrganizationsSlugWebhooksEndpointsDelete, + input, + options, + ); + }), + organizationsSlugWebhooksEndpointsGet: ( + input: typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsGet.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsGet.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2OrganizationsSlugWebhooksEndpointsGet">( + operationDefinitions.v2OrganizationsSlugWebhooksEndpointsGet, + input, + options, + ); + }), + organizationsSlugWebhooksEndpointsIdDelete: ( + input: typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdDelete.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdDelete.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2OrganizationsSlugWebhooksEndpointsIdDelete">( + operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdDelete, + input, + options, + ); + }), + organizationsSlugWebhooksEndpointsIdDeliveriesGet: ( + input: typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdDeliveriesGet.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdDeliveriesGet.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2OrganizationsSlugWebhooksEndpointsIdDeliveriesGet">( + operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdDeliveriesGet, + input, + options, + ); + }), + organizationsSlugWebhooksEndpointsIdGet: ( + input: typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdGet.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdGet.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2OrganizationsSlugWebhooksEndpointsIdGet">( + operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdGet, + input, + options, + ); + }), + organizationsSlugWebhooksEndpointsIdPatch: ( + input: typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdPatch.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdPatch.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2OrganizationsSlugWebhooksEndpointsIdPatch">( + operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdPatch, + input, + options, + ); + }), + organizationsSlugWebhooksEndpointsIdTestPost: ( + input: typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdTestPost.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdTestPost.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2OrganizationsSlugWebhooksEndpointsIdTestPost">( + operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdTestPost, + input, + options, + ); + }), + organizationsSlugWebhooksEndpointsPost: ( + input: typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsPost.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsPost.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2OrganizationsSlugWebhooksEndpointsPost">( + operationDefinitions.v2OrganizationsSlugWebhooksEndpointsPost, + input, + options, ); }), previewAProjectTransfer: ( input: typeof operationDefinitions.v2PreviewAProjectTransfer.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2PreviewAProjectTransfer.outputSchema.Type, SupabaseApiError, @@ -2700,10 +3297,172 @@ export const versionedEffectOperations = { return yield* client.execute<"v2PreviewAProjectTransfer">( operationDefinitions.v2PreviewAProjectTransfer, input, + options, + ); + }), + projectsRefWebhooksDeliveriesIdGet: ( + input: typeof operationDefinitions.v2ProjectsRefWebhooksDeliveriesIdGet.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2ProjectsRefWebhooksDeliveriesIdGet.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ProjectsRefWebhooksDeliveriesIdGet">( + operationDefinitions.v2ProjectsRefWebhooksDeliveriesIdGet, + input, + options, + ); + }), + projectsRefWebhooksDeliveriesIdRetryPost: ( + input: typeof operationDefinitions.v2ProjectsRefWebhooksDeliveriesIdRetryPost.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2ProjectsRefWebhooksDeliveriesIdRetryPost.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ProjectsRefWebhooksDeliveriesIdRetryPost">( + operationDefinitions.v2ProjectsRefWebhooksDeliveriesIdRetryPost, + input, + options, + ); + }), + projectsRefWebhooksEndpointsDelete: ( + input: typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsDelete.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsDelete.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ProjectsRefWebhooksEndpointsDelete">( + operationDefinitions.v2ProjectsRefWebhooksEndpointsDelete, + input, + options, + ); + }), + projectsRefWebhooksEndpointsGet: ( + input: typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsGet.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsGet.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ProjectsRefWebhooksEndpointsGet">( + operationDefinitions.v2ProjectsRefWebhooksEndpointsGet, + input, + options, + ); + }), + projectsRefWebhooksEndpointsIdDelete: ( + input: typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsIdDelete.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsIdDelete.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ProjectsRefWebhooksEndpointsIdDelete">( + operationDefinitions.v2ProjectsRefWebhooksEndpointsIdDelete, + input, + options, + ); + }), + projectsRefWebhooksEndpointsIdDeliveriesGet: ( + input: typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsIdDeliveriesGet.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsIdDeliveriesGet.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ProjectsRefWebhooksEndpointsIdDeliveriesGet">( + operationDefinitions.v2ProjectsRefWebhooksEndpointsIdDeliveriesGet, + input, + options, + ); + }), + projectsRefWebhooksEndpointsIdGet: ( + input: typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsIdGet.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsIdGet.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ProjectsRefWebhooksEndpointsIdGet">( + operationDefinitions.v2ProjectsRefWebhooksEndpointsIdGet, + input, + options, + ); + }), + projectsRefWebhooksEndpointsIdPatch: ( + input: typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsIdPatch.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsIdPatch.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ProjectsRefWebhooksEndpointsIdPatch">( + operationDefinitions.v2ProjectsRefWebhooksEndpointsIdPatch, + input, + options, + ); + }), + projectsRefWebhooksEndpointsIdTestPost: ( + input: typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsIdTestPost.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsIdTestPost.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ProjectsRefWebhooksEndpointsIdTestPost">( + operationDefinitions.v2ProjectsRefWebhooksEndpointsIdTestPost, + input, + options, + ); + }), + projectsRefWebhooksEndpointsPost: ( + input: typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsPost.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsPost.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ProjectsRefWebhooksEndpointsPost">( + operationDefinitions.v2ProjectsRefWebhooksEndpointsPost, + input, + options, ); }), runProjectAdvisors: ( input: typeof operationDefinitions.v2RunProjectAdvisors.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2RunProjectAdvisors.outputSchema.Type, SupabaseApiError, @@ -2714,10 +3473,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2RunProjectAdvisors">( operationDefinitions.v2RunProjectAdvisors, input, + options, ); }), transferAProject: ( input: typeof operationDefinitions.v2TransferAProject.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2TransferAProject.outputSchema.Type, SupabaseApiError, @@ -2728,10 +3489,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2TransferAProject">( operationDefinitions.v2TransferAProject, input, + options, ); }), updateLogDrain: ( input: typeof operationDefinitions.v2UpdateLogDrain.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2UpdateLogDrain.outputSchema.Type, SupabaseApiError, @@ -2742,10 +3505,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2UpdateLogDrain">( operationDefinitions.v2UpdateLogDrain, input, + options, ); }), updateNotebook: ( input: typeof operationDefinitions.v2UpdateNotebook.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2UpdateNotebook.outputSchema.Type, SupabaseApiError, @@ -2756,821 +3521,10 @@ export const versionedEffectOperations = { return yield* client.execute<"v2UpdateNotebook">( operationDefinitions.v2UpdateNotebook, input, + options, ); }), }, } as const; export type GeneratedEffectOperations = typeof versionedEffectOperations; -type GeneratedApiClient = EffectClient; - -export function executeApiClientOperation( - operationId: keyof typeof operationDefinitions, - api: GeneratedApiClient, - input: unknown, -) { - switch (operationId) { - case "v1AcceptInviteExternalJitAccess": - return Schema.decodeUnknownEffect( - operationDefinitions.v1AcceptInviteExternalJitAccess.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.acceptInviteExternalJitAccess(decoded))); - case "v1ActivateCustomHostname": - return Schema.decodeUnknownEffect(operationDefinitions.v1ActivateCustomHostname.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.activateCustomHostname(decoded))); - case "v1ActivateVanitySubdomainConfig": - return Schema.decodeUnknownEffect( - operationDefinitions.v1ActivateVanitySubdomainConfig.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.activateVanitySubdomainConfig(decoded))); - case "v1ApplyAMigration": - return Schema.decodeUnknownEffect(operationDefinitions.v1ApplyAMigration.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.applyAMigration(decoded))); - case "v1ApplyProjectAddon": - return Schema.decodeUnknownEffect(operationDefinitions.v1ApplyProjectAddon.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.applyProjectAddon(decoded))); - case "v1AuthorizeJitAccess": - return Schema.decodeUnknownEffect(operationDefinitions.v1AuthorizeJitAccess.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.authorizeJitAccess(decoded))); - case "v1AuthorizeUser": - return Schema.decodeUnknownEffect(operationDefinitions.v1AuthorizeUser.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.authorizeUser(decoded))); - case "v1BulkCreateSecrets": - return Schema.decodeUnknownEffect(operationDefinitions.v1BulkCreateSecrets.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.bulkCreateSecrets(decoded))); - case "v1BulkDeleteSecrets": - return Schema.decodeUnknownEffect(operationDefinitions.v1BulkDeleteSecrets.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.bulkDeleteSecrets(decoded))); - case "v1BulkUpdateFunctions": - return Schema.decodeUnknownEffect(operationDefinitions.v1BulkUpdateFunctions.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.bulkUpdateFunctions(decoded))); - case "v1CancelAProjectRestoration": - return Schema.decodeUnknownEffect( - operationDefinitions.v1CancelAProjectRestoration.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.cancelAProjectRestoration(decoded))); - case "v1CheckVanitySubdomainAvailability": - return Schema.decodeUnknownEffect( - operationDefinitions.v1CheckVanitySubdomainAvailability.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.checkVanitySubdomainAvailability(decoded))); - case "v1ClaimProjectForOrganization": - return Schema.decodeUnknownEffect( - operationDefinitions.v1ClaimProjectForOrganization.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.claimProjectForOrganization(decoded))); - case "v1CountActionRuns": - return Schema.decodeUnknownEffect(operationDefinitions.v1CountActionRuns.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.countActionRuns(decoded))); - case "v1CreateABranch": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateABranch.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createABranch(decoded))); - case "v1CreateAFunction": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateAFunction.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createAFunction(decoded))); - case "v1CreateAProject": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateAProject.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createAProject(decoded))); - case "v1CreateASsoProvider": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateASsoProvider.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createASsoProvider(decoded))); - case "v1CreateAnOrganization": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateAnOrganization.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createAnOrganization(decoded))); - case "v1CreateLegacySigningKey": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateLegacySigningKey.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createLegacySigningKey(decoded))); - case "v1CreateLoginRole": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateLoginRole.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createLoginRole(decoded))); - case "v1CreateProjectApiKey": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateProjectApiKey.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createProjectApiKey(decoded))); - case "v1CreateProjectClaimToken": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateProjectClaimToken.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createProjectClaimToken(decoded))); - case "v1CreateProjectSigningKey": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateProjectSigningKey.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createProjectSigningKey(decoded))); - case "v1CreateProjectTpaIntegration": - return Schema.decodeUnknownEffect( - operationDefinitions.v1CreateProjectTpaIntegration.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.createProjectTpaIntegration(decoded))); - case "v1CreateRestorePoint": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateRestorePoint.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createRestorePoint(decoded))); - case "v1DeactivateVanitySubdomainConfig": - return Schema.decodeUnknownEffect( - operationDefinitions.v1DeactivateVanitySubdomainConfig.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.deactivateVanitySubdomainConfig(decoded))); - case "v1DeleteHostnameConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeleteHostnameConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deleteHostnameConfig(decoded))); - case "v1DeleteABranch": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeleteABranch.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deleteABranch(decoded))); - case "v1DeleteAFunction": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeleteAFunction.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deleteAFunction(decoded))); - case "v1DeleteAProject": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeleteAProject.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deleteAProject(decoded))); - case "v1DeleteASsoProvider": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeleteASsoProvider.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deleteASsoProvider(decoded))); - case "v1DeleteInviteExternalJitAccess": - return Schema.decodeUnknownEffect( - operationDefinitions.v1DeleteInviteExternalJitAccess.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.deleteInviteExternalJitAccess(decoded))); - case "v1DeleteJitAccess": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeleteJitAccess.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deleteJitAccess(decoded))); - case "v1DeleteLoginRoles": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeleteLoginRoles.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deleteLoginRoles(decoded))); - case "v1DeleteNetworkBans": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeleteNetworkBans.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deleteNetworkBans(decoded))); - case "v1DeleteProjectApiKey": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeleteProjectApiKey.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deleteProjectApiKey(decoded))); - case "v1DeleteProjectClaimToken": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeleteProjectClaimToken.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deleteProjectClaimToken(decoded))); - case "v1DeleteProjectTpaIntegration": - return Schema.decodeUnknownEffect( - operationDefinitions.v1DeleteProjectTpaIntegration.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.deleteProjectTpaIntegration(decoded))); - case "v1DeployAFunction": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeployAFunction.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deployAFunction(decoded))); - case "v1DiffABranch": - return Schema.decodeUnknownEffect(operationDefinitions.v1DiffABranch.inputSchema)(input).pipe( - Effect.flatMap((decoded) => api.v1.diffABranch(decoded)), - ); - case "v1DisablePreviewBranching": - return Schema.decodeUnknownEffect(operationDefinitions.v1DisablePreviewBranching.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.disablePreviewBranching(decoded))); - case "v1DisableReadonlyModeTemporarily": - return Schema.decodeUnknownEffect( - operationDefinitions.v1DisableReadonlyModeTemporarily.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.disableReadonlyModeTemporarily(decoded))); - case "v1EnableDatabaseWebhook": - return Schema.decodeUnknownEffect(operationDefinitions.v1EnableDatabaseWebhook.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.enableDatabaseWebhook(decoded))); - case "v1ExchangeOauthToken": - return Schema.decodeUnknownEffect(operationDefinitions.v1ExchangeOauthToken.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.exchangeOauthToken(decoded))); - case "v1GenerateTypescriptTypes": - return Schema.decodeUnknownEffect(operationDefinitions.v1GenerateTypescriptTypes.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.generateTypescriptTypes(decoded))); - case "v1GetABranch": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetABranch.inputSchema)(input).pipe( - Effect.flatMap((decoded) => api.v1.getABranch(decoded)), - ); - case "v1GetABranchConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetABranchConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getABranchConfig(decoded))); - case "v1GetAFunction": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetAFunction.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getAFunction(decoded))); - case "v1GetAFunctionBody": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetAFunctionBody.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getAFunctionBody(decoded))); - case "v1GetAMigration": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetAMigration.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getAMigration(decoded))); - case "v1GetASnippet": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetASnippet.inputSchema)(input).pipe( - Effect.flatMap((decoded) => api.v1.getASnippet(decoded)), - ); - case "v1GetASsoProvider": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetASsoProvider.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getASsoProvider(decoded))); - case "v1GetActionRun": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetActionRun.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getActionRun(decoded))); - case "v1GetActionRunLogs": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetActionRunLogs.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getActionRunLogs(decoded))); - case "v1GetAllProjectsForOrganization": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetAllProjectsForOrganization.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getAllProjectsForOrganization(decoded))); - case "v1GetAnOrganization": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetAnOrganization.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getAnOrganization(decoded))); - case "v1GetAuthServiceConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetAuthServiceConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getAuthServiceConfig(decoded))); - case "v1GetAvailableRegions": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetAvailableRegions.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getAvailableRegions(decoded))); - case "v1GetBackupSchedule": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetBackupSchedule.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getBackupSchedule(decoded))); - case "v1GetDatabaseDisk": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetDatabaseDisk.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getDatabaseDisk(decoded))); - case "v1GetDatabaseMetadata": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetDatabaseMetadata.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getDatabaseMetadata(decoded))); - case "v1GetDatabaseOpenapi": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetDatabaseOpenapi.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getDatabaseOpenapi(decoded))); - case "v1GetDiskUtilization": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetDiskUtilization.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getDiskUtilization(decoded))); - case "v1GetHostnameConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetHostnameConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getHostnameConfig(decoded))); - case "v1GetJitAccess": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetJitAccess.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getJitAccess(decoded))); - case "v1GetJitAccessConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetJitAccessConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getJitAccessConfig(decoded))); - case "v1GetLegacySigningKey": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetLegacySigningKey.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getLegacySigningKey(decoded))); - case "v1GetNetworkRestrictions": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetNetworkRestrictions.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getNetworkRestrictions(decoded))); - case "v1GetOrganizationEntitlements": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetOrganizationEntitlements.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getOrganizationEntitlements(decoded))); - case "v1GetOrganizationProjectClaim": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetOrganizationProjectClaim.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getOrganizationProjectClaim(decoded))); - case "v1GetPerformanceAdvisors": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetPerformanceAdvisors.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getPerformanceAdvisors(decoded))); - case "v1GetPgsodiumConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetPgsodiumConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getPgsodiumConfig(decoded))); - case "v1GetPoolerConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetPoolerConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getPoolerConfig(decoded))); - case "v1GetPostgresConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetPostgresConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getPostgresConfig(decoded))); - case "v1GetPostgresUpgradeEligibility": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetPostgresUpgradeEligibility.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getPostgresUpgradeEligibility(decoded))); - case "v1GetPostgresUpgradeStatus": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetPostgresUpgradeStatus.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getPostgresUpgradeStatus(decoded))); - case "v1GetPostgrestServiceConfig": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetPostgrestServiceConfig.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getPostgrestServiceConfig(decoded))); - case "v1GetProfile": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProfile.inputSchema)(input).pipe( - Effect.flatMap((_decoded) => api.v1.getProfile()), - ); - case "v1GetProject": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProject.inputSchema)(input).pipe( - Effect.flatMap((decoded) => api.v1.getProject(decoded)), - ); - case "v1GetProjectApiKey": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProjectApiKey.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getProjectApiKey(decoded))); - case "v1GetProjectApiKeys": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProjectApiKeys.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getProjectApiKeys(decoded))); - case "v1GetProjectClaimToken": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProjectClaimToken.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getProjectClaimToken(decoded))); - case "v1GetProjectDiskAutoscaleConfig": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetProjectDiskAutoscaleConfig.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getProjectDiskAutoscaleConfig(decoded))); - case "v1GetProjectFunctionCombinedStats": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetProjectFunctionCombinedStats.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getProjectFunctionCombinedStats(decoded))); - case "v1GetProjectLegacyApiKeys": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProjectLegacyApiKeys.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getProjectLegacyApiKeys(decoded))); - case "v1GetProjectLogs": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProjectLogs.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getProjectLogs(decoded))); - case "v1GetProjectLogsAll": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProjectLogsAll.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getProjectLogsAll(decoded))); - case "v1GetProjectPgbouncerConfig": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetProjectPgbouncerConfig.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getProjectPgbouncerConfig(decoded))); - case "v1GetProjectSigningKey": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProjectSigningKey.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getProjectSigningKey(decoded))); - case "v1GetProjectSigningKeys": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProjectSigningKeys.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getProjectSigningKeys(decoded))); - case "v1GetProjectTpaIntegration": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetProjectTpaIntegration.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getProjectTpaIntegration(decoded))); - case "v1GetProjectUsageApiCount": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProjectUsageApiCount.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getProjectUsageApiCount(decoded))); - case "v1GetProjectUsageRequestCount": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetProjectUsageRequestCount.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getProjectUsageRequestCount(decoded))); - case "v1GetReadonlyModeStatus": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetReadonlyModeStatus.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getReadonlyModeStatus(decoded))); - case "v1GetRealtimeConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetRealtimeConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getRealtimeConfig(decoded))); - case "v1GetRestorePoint": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetRestorePoint.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getRestorePoint(decoded))); - case "v1GetSecurityAdvisors": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetSecurityAdvisors.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getSecurityAdvisors(decoded))); - case "v1GetServicesHealth": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetServicesHealth.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getServicesHealth(decoded))); - case "v1GetSslEnforcementConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetSslEnforcementConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getSslEnforcementConfig(decoded))); - case "v1GetStorageConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetStorageConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getStorageConfig(decoded))); - case "v1GetVanitySubdomainConfig": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetVanitySubdomainConfig.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getVanitySubdomainConfig(decoded))); - case "v1InviteExternalJitAccess": - return Schema.decodeUnknownEffect(operationDefinitions.v1InviteExternalJitAccess.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.inviteExternalJitAccess(decoded))); - case "v1ListActionRuns": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListActionRuns.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listActionRuns(decoded))); - case "v1ListAllBackups": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListAllBackups.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listAllBackups(decoded))); - case "v1ListAllBranches": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListAllBranches.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listAllBranches(decoded))); - case "v1ListAllBuckets": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListAllBuckets.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listAllBuckets(decoded))); - case "v1ListAllFunctions": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListAllFunctions.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listAllFunctions(decoded))); - case "v1ListAllNetworkBans": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListAllNetworkBans.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listAllNetworkBans(decoded))); - case "v1ListAllNetworkBansEnriched": - return Schema.decodeUnknownEffect( - operationDefinitions.v1ListAllNetworkBansEnriched.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.listAllNetworkBansEnriched(decoded))); - case "v1ListAllOrganizations": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListAllOrganizations.inputSchema)( - input, - ).pipe(Effect.flatMap((_decoded) => api.v1.listAllOrganizations())); - case "v1ListAllProjects": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListAllProjects.inputSchema)( - input, - ).pipe(Effect.flatMap((_decoded) => api.v1.listAllProjects())); - case "v1ListAllSecrets": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListAllSecrets.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listAllSecrets(decoded))); - case "v1ListAllSnippets": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListAllSnippets.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listAllSnippets(decoded))); - case "v1ListAllSsoProvider": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListAllSsoProvider.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listAllSsoProvider(decoded))); - case "v1ListAvailableRestoreVersions": - return Schema.decodeUnknownEffect( - operationDefinitions.v1ListAvailableRestoreVersions.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.listAvailableRestoreVersions(decoded))); - case "v1ListJitAccess": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListJitAccess.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listJitAccess(decoded))); - case "v1ListMigrationHistory": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListMigrationHistory.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listMigrationHistory(decoded))); - case "v1ListOrganizationMembers": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListOrganizationMembers.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listOrganizationMembers(decoded))); - case "v1ListProjectAddons": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListProjectAddons.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listProjectAddons(decoded))); - case "v1ListProjectTpaIntegrations": - return Schema.decodeUnknownEffect( - operationDefinitions.v1ListProjectTpaIntegrations.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.listProjectTpaIntegrations(decoded))); - case "v1MergeABranch": - return Schema.decodeUnknownEffect(operationDefinitions.v1MergeABranch.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.mergeABranch(decoded))); - case "v1ModifyDatabaseDisk": - return Schema.decodeUnknownEffect(operationDefinitions.v1ModifyDatabaseDisk.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.modifyDatabaseDisk(decoded))); - case "v1OauthAuthorizeProjectClaim": - return Schema.decodeUnknownEffect( - operationDefinitions.v1OauthAuthorizeProjectClaim.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.oauthAuthorizeProjectClaim(decoded))); - case "v1PatchAMigration": - return Schema.decodeUnknownEffect(operationDefinitions.v1PatchAMigration.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.patchAMigration(decoded))); - case "v1PatchNetworkRestrictions": - return Schema.decodeUnknownEffect( - operationDefinitions.v1PatchNetworkRestrictions.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.patchNetworkRestrictions(decoded))); - case "v1PauseAProject": - return Schema.decodeUnknownEffect(operationDefinitions.v1PauseAProject.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.pauseAProject(decoded))); - case "v1PushABranch": - return Schema.decodeUnknownEffect(operationDefinitions.v1PushABranch.inputSchema)(input).pipe( - Effect.flatMap((decoded) => api.v1.pushABranch(decoded)), - ); - case "v1ReadOnlyQuery": - return Schema.decodeUnknownEffect(operationDefinitions.v1ReadOnlyQuery.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.readOnlyQuery(decoded))); - case "v1RemoveAReadReplica": - return Schema.decodeUnknownEffect(operationDefinitions.v1RemoveAReadReplica.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.removeAReadReplica(decoded))); - case "v1RemoveProjectAddon": - return Schema.decodeUnknownEffect(operationDefinitions.v1RemoveProjectAddon.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.removeProjectAddon(decoded))); - case "v1RemoveProjectSigningKey": - return Schema.decodeUnknownEffect(operationDefinitions.v1RemoveProjectSigningKey.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.removeProjectSigningKey(decoded))); - case "v1ResetABranch": - return Schema.decodeUnknownEffect(operationDefinitions.v1ResetABranch.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.resetABranch(decoded))); - case "v1RestartAProject": - return Schema.decodeUnknownEffect(operationDefinitions.v1RestartAProject.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.restartAProject(decoded))); - case "v1RestoreABranch": - return Schema.decodeUnknownEffect(operationDefinitions.v1RestoreABranch.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.restoreABranch(decoded))); - case "v1RestoreAProject": - return Schema.decodeUnknownEffect(operationDefinitions.v1RestoreAProject.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.restoreAProject(decoded))); - case "v1RestorePhysicalBackup": - return Schema.decodeUnknownEffect(operationDefinitions.v1RestorePhysicalBackup.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.restorePhysicalBackup(decoded))); - case "v1RestorePitrBackup": - return Schema.decodeUnknownEffect(operationDefinitions.v1RestorePitrBackup.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.restorePitrBackup(decoded))); - case "v1RevokeToken": - return Schema.decodeUnknownEffect(operationDefinitions.v1RevokeToken.inputSchema)(input).pipe( - Effect.flatMap((decoded) => api.v1.revokeToken(decoded)), - ); - case "v1RollbackMigrations": - return Schema.decodeUnknownEffect(operationDefinitions.v1RollbackMigrations.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.rollbackMigrations(decoded))); - case "v1RunAQuery": - return Schema.decodeUnknownEffect(operationDefinitions.v1RunAQuery.inputSchema)(input).pipe( - Effect.flatMap((decoded) => api.v1.runAQuery(decoded)), - ); - case "v1ScrapeProjectMetrics": - return Schema.decodeUnknownEffect(operationDefinitions.v1ScrapeProjectMetrics.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.scrapeProjectMetrics(decoded))); - case "v1SetupAReadReplica": - return Schema.decodeUnknownEffect(operationDefinitions.v1SetupAReadReplica.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.setupAReadReplica(decoded))); - case "v1ShutdownRealtime": - return Schema.decodeUnknownEffect(operationDefinitions.v1ShutdownRealtime.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.shutdownRealtime(decoded))); - case "v1Undo": - return Schema.decodeUnknownEffect(operationDefinitions.v1Undo.inputSchema)(input).pipe( - Effect.flatMap((decoded) => api.v1.undo(decoded)), - ); - case "v1UpdateABranchConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateABranchConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateABranchConfig(decoded))); - case "v1UpdateAFunction": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateAFunction.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateAFunction(decoded))); - case "v1UpdateAProject": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateAProject.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateAProject(decoded))); - case "v1UpdateASsoProvider": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateASsoProvider.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateASsoProvider(decoded))); - case "v1UpdateActionRunStatus": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateActionRunStatus.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateActionRunStatus(decoded))); - case "v1UpdateAuthServiceConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateAuthServiceConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateAuthServiceConfig(decoded))); - case "v1UpdateBackupSchedule": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateBackupSchedule.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateBackupSchedule(decoded))); - case "v1UpdateDatabasePassword": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateDatabasePassword.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateDatabasePassword(decoded))); - case "v1UpdateHostnameConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateHostnameConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateHostnameConfig(decoded))); - case "v1UpdateJitAccess": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateJitAccess.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateJitAccess(decoded))); - case "v1UpdateJitAccessConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateJitAccessConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateJitAccessConfig(decoded))); - case "v1UpdateNetworkRestrictions": - return Schema.decodeUnknownEffect( - operationDefinitions.v1UpdateNetworkRestrictions.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.updateNetworkRestrictions(decoded))); - case "v1UpdatePgsodiumConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdatePgsodiumConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updatePgsodiumConfig(decoded))); - case "v1UpdatePoolerConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdatePoolerConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updatePoolerConfig(decoded))); - case "v1UpdatePostgresConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdatePostgresConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updatePostgresConfig(decoded))); - case "v1UpdatePostgrestServiceConfig": - return Schema.decodeUnknownEffect( - operationDefinitions.v1UpdatePostgrestServiceConfig.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.updatePostgrestServiceConfig(decoded))); - case "v1UpdateProjectApiKey": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateProjectApiKey.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateProjectApiKey(decoded))); - case "v1UpdateProjectLegacyApiKeys": - return Schema.decodeUnknownEffect( - operationDefinitions.v1UpdateProjectLegacyApiKeys.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.updateProjectLegacyApiKeys(decoded))); - case "v1UpdateProjectSigningKey": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateProjectSigningKey.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateProjectSigningKey(decoded))); - case "v1UpdateRealtimeConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateRealtimeConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateRealtimeConfig(decoded))); - case "v1UpdateSslEnforcementConfig": - return Schema.decodeUnknownEffect( - operationDefinitions.v1UpdateSslEnforcementConfig.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.updateSslEnforcementConfig(decoded))); - case "v1UpdateStorageConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateStorageConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateStorageConfig(decoded))); - case "v1UpgradePostgresVersion": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpgradePostgresVersion.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.upgradePostgresVersion(decoded))); - case "v1UpsertAMigration": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpsertAMigration.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.upsertAMigration(decoded))); - case "v1VerifyDnsConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1VerifyDnsConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.verifyDnsConfig(decoded))); - case "v2AssignOrganizationMemberRole": - return Schema.decodeUnknownEffect( - operationDefinitions.v2AssignOrganizationMemberRole.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v2.assignOrganizationMemberRole(decoded))); - case "v2CreateABranch": - return Schema.decodeUnknownEffect(operationDefinitions.v2CreateABranch.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.createABranch(decoded))); - case "v2CreateComputeInstanceUpload": - return Schema.decodeUnknownEffect( - operationDefinitions.v2CreateComputeInstanceUpload.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v2.createComputeInstanceUpload(decoded))); - case "v2CreateLogDrain": - return Schema.decodeUnknownEffect(operationDefinitions.v2CreateLogDrain.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.createLogDrain(decoded))); - case "v2CreateNotebook": - return Schema.decodeUnknownEffect(operationDefinitions.v2CreateNotebook.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.createNotebook(decoded))); - case "v2CreateOrganizationInvitations": - return Schema.decodeUnknownEffect( - operationDefinitions.v2CreateOrganizationInvitations.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v2.createOrganizationInvitations(decoded))); - case "v2CreatePrivateLinkAssociation": - return Schema.decodeUnknownEffect( - operationDefinitions.v2CreatePrivateLinkAssociation.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v2.createPrivateLinkAssociation(decoded))); - case "v2DeleteAComputeInstance": - return Schema.decodeUnknownEffect(operationDefinitions.v2DeleteAComputeInstance.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.deleteAComputeInstance(decoded))); - case "v2DeleteLogDrain": - return Schema.decodeUnknownEffect(operationDefinitions.v2DeleteLogDrain.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.deleteLogDrain(decoded))); - case "v2DeleteNotebook": - return Schema.decodeUnknownEffect(operationDefinitions.v2DeleteNotebook.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.deleteNotebook(decoded))); - case "v2DeleteOrganizationInvitations": - return Schema.decodeUnknownEffect( - operationDefinitions.v2DeleteOrganizationInvitations.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v2.deleteOrganizationInvitations(decoded))); - case "v2DeletePrivateLinkAssociation": - return Schema.decodeUnknownEffect( - operationDefinitions.v2DeletePrivateLinkAssociation.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v2.deletePrivateLinkAssociation(decoded))); - case "v2DeletePrivateLinkAssociationForDatabase": - return Schema.decodeUnknownEffect( - operationDefinitions.v2DeletePrivateLinkAssociationForDatabase.inputSchema, - )(input).pipe( - Effect.flatMap((decoded) => api.v2.deletePrivateLinkAssociationForDatabase(decoded)), - ); - case "v2DeployAComputeInstance": - return Schema.decodeUnknownEffect(operationDefinitions.v2DeployAComputeInstance.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.deployAComputeInstance(decoded))); - case "v2GetAComputeInstance": - return Schema.decodeUnknownEffect(operationDefinitions.v2GetAComputeInstance.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.getAComputeInstance(decoded))); - case "v2GetNotebook": - return Schema.decodeUnknownEffect(operationDefinitions.v2GetNotebook.inputSchema)(input).pipe( - Effect.flatMap((decoded) => api.v2.getNotebook(decoded)), - ); - case "v2GetProjectConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v2GetProjectConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.getProjectConfig(decoded))); - case "v2ListAllComputeInstances": - return Schema.decodeUnknownEffect(operationDefinitions.v2ListAllComputeInstances.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.listAllComputeInstances(decoded))); - case "v2ListLogDrains": - return Schema.decodeUnknownEffect(operationDefinitions.v2ListLogDrains.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.listLogDrains(decoded))); - case "v2ListNotebooks": - return Schema.decodeUnknownEffect(operationDefinitions.v2ListNotebooks.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.listNotebooks(decoded))); - case "v2ListOrganizationGithubConnections": - return Schema.decodeUnknownEffect( - operationDefinitions.v2ListOrganizationGithubConnections.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v2.listOrganizationGithubConnections(decoded))); - case "v2ListOrganizationMembers": - return Schema.decodeUnknownEffect(operationDefinitions.v2ListOrganizationMembers.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.listOrganizationMembers(decoded))); - case "v2ListOrganizationProjects": - return Schema.decodeUnknownEffect( - operationDefinitions.v2ListOrganizationProjects.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v2.listOrganizationProjects(decoded))); - case "v2ListOrganizationRoles": - return Schema.decodeUnknownEffect(operationDefinitions.v2ListOrganizationRoles.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.listOrganizationRoles(decoded))); - case "v2ListPrivateLinkAssociations": - return Schema.decodeUnknownEffect( - operationDefinitions.v2ListPrivateLinkAssociations.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v2.listPrivateLinkAssociations(decoded))); - case "v2PreviewAProjectTransfer": - return Schema.decodeUnknownEffect(operationDefinitions.v2PreviewAProjectTransfer.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.previewAProjectTransfer(decoded))); - case "v2RunProjectAdvisors": - return Schema.decodeUnknownEffect(operationDefinitions.v2RunProjectAdvisors.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.runProjectAdvisors(decoded))); - case "v2TransferAProject": - return Schema.decodeUnknownEffect(operationDefinitions.v2TransferAProject.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.transferAProject(decoded))); - case "v2UpdateLogDrain": - return Schema.decodeUnknownEffect(operationDefinitions.v2UpdateLogDrain.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.updateLogDrain(decoded))); - case "v2UpdateNotebook": - return Schema.decodeUnknownEffect(operationDefinitions.v2UpdateNotebook.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.updateNotebook(decoded))); - } -} diff --git a/packages/api/src/generated/openapi.json b/packages/api/src/generated/openapi.json index 374fd9a68e..0d81577761 100644 --- a/packages/api/src/generated/openapi.json +++ b/packages/api/src/generated/openapi.json @@ -14583,6 +14583,16924 @@ "x-fga-permissions": [["organization_projects_read"]], "x-oauth-scope": "projects:read" } + }, + "/v2/projects/{ref}/webhooks/endpoints": { + "get": { + "operationId": "v2-projects-ref-webhooks-endpoints-get", + "parameters": [ + { + "in": "path", + "name": "ref", + "schema": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst" + }, + "required": true, + "description": "Project ref" + }, + { + "in": "query", + "name": "page[offset]", + "schema": { + "default": "0", + "type": "string", + "pattern": "^\\d+$" + }, + "description": "Offset for offset-based pagination.\n\nOffset should be dividable by `page[limit]` without a reminder (`offset % limit === 0`), otherwise it will cause weird behavior when used on a frontend." + }, + { + "in": "query", + "name": "page[limit]", + "schema": { + "default": "20", + "type": "string", + "pattern": "^\\d+$" + }, + "description": "Up to how many records to return." + } + ], + "tags": ["Project webhooks"], + "responses": { + "200": { + "description": "Collection of endpoints", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "links": { + "type": "object", + "properties": { + "first": { + "description": "URL path to the first page if available.", + "example": "/v2/organization/slug/webhooks/endpoints?page[limit]=10&page[offset]=0", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "prev": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "URL path to the previous page.", + "example": "/v2/organization/slug/webhooks/endpoints?page[limit]=10&page[offset]=0" + }, + "next": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "URL path to the next page.", + "example": "/v2/organization/slug/webhooks/endpoints?page[limit]=10&page[offset]=20" + }, + "last": { + "description": "URL path to the last page if available.", + "example": "/v2/organization/slug/webhooks/endpoints?page[limit]=10&page[offset]=30", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["prev", "next"] + } + }, + "required": ["data", "links"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "List endpoints", + "description": "List all Webhook endpoints based on a project's ref or an organization's slug." + }, + "post": { + "operationId": "v2-projects-ref-webhooks-endpoints-post", + "parameters": [ + { + "in": "path", + "name": "ref", + "schema": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst" + }, + "required": true, + "description": "Project ref" + } + ], + "tags": ["Project webhooks"], + "responses": { + "201": { + "description": "Created endpoint", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Create endpoint", + "description": "Create new endpoint configuration to subscribe to specific webhook events.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "default": true, + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + } + }, + "signing_secret": { + "type": "string", + "minLength": 8, + "maxLength": 64, + "description": "Secret key to use when signing. All events will be signed according to the [Standard Webhooks](https://www.standardwebhooks.com/) specification." + } + }, + "required": ["url", "event_types", "signing_secret"] + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + } + } + } + } + }, + "delete": { + "operationId": "v2-projects-ref-webhooks-endpoints-delete", + "parameters": [ + { + "in": "path", + "name": "ref", + "schema": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst" + }, + "required": true, + "description": "Project ref" + } + ], + "tags": ["Project webhooks"], + "responses": { + "200": { + "description": "Deleted endpoints", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Delete all endpoints", + "description": "Delete all endpoints including all events and deliveries.\n\nAny in-flight webhooks will result in a no-op." + } + }, + "/v2/projects/{ref}/webhooks/endpoints/{id}": { + "get": { + "operationId": "v2-projects-ref-webhooks-endpoints-id-get", + "parameters": [ + { + "in": "path", + "name": "ref", + "schema": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst" + }, + "required": true, + "description": "Project ref" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of an endpoint (UUID v7)." + } + ], + "tags": ["Project webhooks"], + "responses": { + "200": { + "description": "Endpoint details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "EndpointNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_not_found" + }, + "message": { + "type": "string", + "const": "Webhook endpoint not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "endpoint_not_found", + "message": "Webhook endpoint not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Get endpoint", + "description": "Get details of a specific endpoint." + }, + "patch": { + "operationId": "v2-projects-ref-webhooks-endpoints-id-patch", + "parameters": [ + { + "in": "path", + "name": "ref", + "schema": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst" + }, + "required": true, + "description": "Project ref" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of an endpoint (UUID v7)." + } + ], + "tags": ["Project webhooks"], + "responses": { + "200": { + "description": "Updated endpoint details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "EndpointNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_not_found" + }, + "message": { + "type": "string", + "const": "Webhook endpoint not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "endpoint_not_found", + "message": "Webhook endpoint not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Update endpoint", + "description": "Update endpoint's configuration.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + } + }, + "signing_secret": { + "type": "string", + "minLength": 8, + "maxLength": 64, + "description": "Secret key to use when signing. All events will be signed according to the [Standard Webhooks](https://www.standardwebhooks.com/) specification." + } + } + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + } + } + } + } + }, + "delete": { + "operationId": "v2-projects-ref-webhooks-endpoints-id-delete", + "parameters": [ + { + "in": "path", + "name": "ref", + "schema": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst" + }, + "required": true, + "description": "Project ref" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of an endpoint (UUID v7)." + } + ], + "tags": ["Project webhooks"], + "responses": { + "200": { + "description": "Deleted endpoint details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "EndpointNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_not_found" + }, + "message": { + "type": "string", + "const": "Webhook endpoint not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "endpoint_not_found", + "message": "Webhook endpoint not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Delete endpoint", + "description": "Delete the endpoint including all events and deliveries\n\nAny in-flight webhooks will result in a no-op." + } + }, + "/v2/projects/{ref}/webhooks/endpoints/{id}/deliveries": { + "get": { + "operationId": "v2-projects-ref-webhooks-endpoints-id-deliveries-get", + "parameters": [ + { + "in": "path", + "name": "ref", + "schema": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst" + }, + "required": true, + "description": "Project ref" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of an endpoint (UUID v7)." + }, + { + "in": "query", + "name": "page[before]", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "description": "Cursor in cursor-based pagination to return up to `page[size]` records before (exclusive) the entry specified by this query param." + }, + { + "in": "query", + "name": "page[after]", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "description": "Cursor in cursor-based pagination to return up to `page[size]` records after (exclusive) the entry specified by this query param." + }, + { + "in": "query", + "name": "page[size]", + "schema": { + "default": "20", + "type": "string", + "pattern": "^\\d+$" + }, + "description": "Up to how many records to return." + } + ], + "tags": ["Project webhooks"], + "responses": { + "200": { + "description": "List of deliveries", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "delivery", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of a delivery (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of a delivery (UUID v7)." + }, + "event_id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of the event, which triggered the delivery (UUID v7)." + }, + "status": { + "type": "string", + "enum": ["pending", "success", "failure", "skipped"], + "description": "Status of the delivery attempt." + }, + "response_code": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "HTTP status code of the response, `0` if unavailable." + }, + "response_headers": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "HTTP headers of the response, `{}` if unavailable." + }, + "response_body": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "Parsed JSON representation of an HTTP body of the response, `{}` if unavailable." + }, + { + "type": "string", + "description": "String representation of an HTTP body of the response." + } + ] + }, + { + "type": "null" + } + ] + }, + "attempt_timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of the attempt." + } + }, + "required": [ + "id", + "event_id", + "status", + "response_code", + "response_headers", + "response_body", + "attempt_timestamp" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "links": { + "type": "object", + "properties": { + "first": { + "description": "URL path to the first page if available.", + "example": "/v2/organizations/slug/webhooks/endpoints/{id}/deliveries?page[size]=10", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "prev": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "URL path to the previous page.", + "example": "/v2/organizations/slug/webhooks/endpoints/{id}/deliveries?page[size]=10&page[before]=019adf7d-4513-74c5-bb9a-f1bc0f7a95d7" + }, + "next": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "URL path to the next page.", + "example": "/v2/organizations/slug/webhooks/endpoints/{id}/deliveries?page[size]=10&page[after]=019adf7d-4513-7062-b292-78b86cc470a4" + }, + "last": { + "description": "URL path to the last page if available.", + "example": "/v2/organizations/slug/webhooks/endpoints/{id}/deliveries?page[size]=10&page[after]=019adf7d-4513-71ba-b264-21900edb4295", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["prev", "next"] + } + }, + "required": ["data", "links"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "EndpointNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_not_found" + }, + "message": { + "type": "string", + "const": "Webhook endpoint not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "endpoint_not_found", + "message": "Webhook endpoint not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "List deliveries", + "description": "List all deliveries for a specific endpoint in descending order (newest first).\n\nDeliveries which has expired are no longer available and will not be listed." + } + }, + "/v2/projects/{ref}/webhooks/endpoints/{id}/test": { + "post": { + "operationId": "v2-projects-ref-webhooks-endpoints-id-test-post", + "parameters": [ + { + "in": "path", + "name": "ref", + "schema": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst" + }, + "required": true, + "description": "Project ref" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of an endpoint (UUID v7)." + } + ], + "tags": ["Project webhooks"], + "responses": { + "201": { + "description": "Event published", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "ingress", + "description": "Resource type." + }, + "id": { + "type": "string" + } + }, + "required": ["type", "id"] + } + }, + "required": ["data"] + } + } + } + }, + "400": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_disabled" + }, + "message": { + "type": "string", + "const": "Webhook endpoint is disabled" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "EndpointTestDisabled" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_event_type_not_subscribed" + }, + "message": { + "type": "string", + "const": "Webhook endpoint is not subscribed to this event type" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "EndpointTestWrongEventType" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "EndpointTestDisabled": { + "value": { + "error": { + "code": "endpoint_disabled", + "description": "Endpoint is disabled, to send test event endpoint must first be enabled.", + "message": "Webhook endpoint is disabled" + } + } + }, + "EndpointTestWrongEventType": { + "value": { + "error": { + "code": "endpoint_event_type_not_subscribed", + "description": "Only event types that the endpoint is subscribed to can be specified.", + "message": "Webhook endpoint is not subscribed to this event type" + } + } + } + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "EndpointNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_not_found" + }, + "message": { + "type": "string", + "const": "Webhook endpoint not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "endpoint_not_found", + "message": "Webhook endpoint not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Send test event", + "description": "Ingests and schedules a test webhook event to be published out this endpoints.\n\nWhich event type to use can be specified in the request body, otherwise\nit will use any matching type the endpoint is listening for.\n\nThe event will contain `is_test: true` in it's payload.\n\nThis endpoint is heavy rate-limited to allow for 10 request within 60 seconds.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "event", + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "type": { + "description": "Webhook event type.", + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "project.v1.paused", + "project.v1.created", + "project.v1.restored", + "project.v1.transferred", + "project.v1.removed", + "project.v1.restarted", + "project.v1.status.changed", + "project.v1.backup.started", + "project.v1.branch.created", + "project.v1.branch.updated", + "project.v1.branch.removed", + "organization.v1.member.invitation.created", + "organization.v1.member.invitation.canceled", + "organization.v1.member.added", + "organization.v1.member.removed", + "organization.v1.member.role.assigned", + "organization.v1.member.role.removed", + "organization.v1.member.role.updated", + "organization.v1.billing.plan.upgraded", + "organization.v1.billing.plan.downgraded", + "project.v1.branch.deleted" + ] + } + }, + "required": ["type"] + } + }, + "required": ["type", "attributes"] + } + } + } + } + } + } + } + }, + "/v2/projects/{ref}/webhooks/deliveries/{id}": { + "get": { + "operationId": "v2-projects-ref-webhooks-deliveries-id-get", + "parameters": [ + { + "in": "path", + "name": "ref", + "schema": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst" + }, + "required": true, + "description": "Project ref" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of a delivery (UUID v7)." + } + ], + "tags": ["Project webhooks"], + "responses": { + "200": { + "description": "Delivery details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "delivery", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of a delivery (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of a delivery (UUID v7)." + }, + "event_id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of the event, which triggered the delivery (UUID v7)." + }, + "status": { + "type": "string", + "enum": ["pending", "success", "failure", "skipped"], + "description": "Status of the delivery attempt." + }, + "response_code": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "HTTP status code of the response, `0` if unavailable." + }, + "response_headers": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "HTTP headers of the response, `{}` if unavailable." + }, + "response_body": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "Parsed JSON representation of an HTTP body of the response, `{}` if unavailable." + }, + { + "type": "string", + "description": "String representation of an HTTP body of the response." + } + ] + }, + { + "type": "null" + } + ] + }, + "attempt_timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of the attempt." + }, + "event": { + "type": "object", + "properties": { + "type": { + "description": "Webhook event type.", + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "project.v1.paused", + "project.v1.created", + "project.v1.restored", + "project.v1.transferred", + "project.v1.removed", + "project.v1.restarted", + "project.v1.status.changed", + "project.v1.backup.started", + "project.v1.branch.created", + "project.v1.branch.updated", + "project.v1.branch.removed", + "organization.v1.member.invitation.created", + "organization.v1.member.invitation.canceled", + "organization.v1.member.added", + "organization.v1.member.removed", + "organization.v1.member.role.assigned", + "organization.v1.member.role.removed", + "organization.v1.member.role.updated", + "organization.v1.billing.plan.upgraded", + "organization.v1.billing.plan.downgraded", + "project.v1.branch.deleted" + ] + }, + "payload": { + "type": "object", + "properties": { + "organization_slug": { + "type": "string", + "pattern": "^[\\w-]+$", + "description": "Organization slug", + "example": "tsrqponmlkjihgfedcba" + }, + "project_ref": { + "anyOf": [ + { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "description": "Project ref", + "example": "abcdefghijklmnopqrst" + }, + { + "type": "null" + } + ] + } + }, + "required": ["organization_slug", "project_ref"], + "description": "Final data sent to the consumer." + }, + "timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of event publication." + } + }, + "required": ["type", "payload", "timestamp"] + } + }, + "required": [ + "id", + "event_id", + "status", + "response_code", + "response_headers", + "response_body", + "attempt_timestamp", + "event" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "DeliveryNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "delivery_not_found" + }, + "message": { + "type": "string", + "const": "Webhook delivery not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "delivery_not_found", + "message": "Webhook delivery not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Get delivery", + "description": "Get details of a specific delivery attempt." + } + }, + "/v2/projects/{ref}/webhooks/deliveries/{id}/retry": { + "post": { + "operationId": "v2-projects-ref-webhooks-deliveries-id-retry-post", + "parameters": [ + { + "in": "path", + "name": "ref", + "schema": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst" + }, + "required": true, + "description": "Project ref" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of a delivery (UUID v7)." + } + ], + "tags": ["Project webhooks"], + "responses": { + "201": { + "description": "Delivery details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "ingress", + "description": "Resource type." + }, + "id": { + "type": "string" + } + }, + "required": ["type", "id"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "DeliveryNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "delivery_not_found" + }, + "message": { + "type": "string", + "const": "Webhook delivery not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "delivery_not_found", + "message": "Webhook delivery not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Retry delivery", + "description": "Retry delivering the same event again.\n\nAutomatic retries are not applicable to manual retries - if the delivery fails, there won't be any automatic retries attempted.\n\nThis endpoint is heavy rate-limited to allow for 10 request within 60 seconds." + } + }, + "/v2/organizations/{slug}/webhooks/endpoints": { + "get": { + "operationId": "v2-organizations-slug-webhooks-endpoints-get", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string", + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba" + }, + "required": true, + "description": "Organization slug" + }, + { + "in": "query", + "name": "page[offset]", + "schema": { + "default": "0", + "type": "string", + "pattern": "^\\d+$" + }, + "description": "Offset for offset-based pagination.\n\nOffset should be dividable by `page[limit]` without a reminder (`offset % limit === 0`), otherwise it will cause weird behavior when used on a frontend." + }, + { + "in": "query", + "name": "page[limit]", + "schema": { + "default": "20", + "type": "string", + "pattern": "^\\d+$" + }, + "description": "Up to how many records to return." + } + ], + "tags": ["Organization webhooks"], + "responses": { + "200": { + "description": "Collection of endpoints", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "links": { + "type": "object", + "properties": { + "first": { + "description": "URL path to the first page if available.", + "example": "/v2/organization/slug/webhooks/endpoints?page[limit]=10&page[offset]=0", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "prev": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "URL path to the previous page.", + "example": "/v2/organization/slug/webhooks/endpoints?page[limit]=10&page[offset]=0" + }, + "next": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "URL path to the next page.", + "example": "/v2/organization/slug/webhooks/endpoints?page[limit]=10&page[offset]=20" + }, + "last": { + "description": "URL path to the last page if available.", + "example": "/v2/organization/slug/webhooks/endpoints?page[limit]=10&page[offset]=30", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["prev", "next"] + } + }, + "required": ["data", "links"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "List endpoints", + "description": "List all Webhook endpoints based on a project's ref or an organization's slug." + }, + "post": { + "operationId": "v2-organizations-slug-webhooks-endpoints-post", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string", + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba" + }, + "required": true, + "description": "Organization slug" + } + ], + "tags": ["Organization webhooks"], + "responses": { + "201": { + "description": "Created endpoint", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Create endpoint", + "description": "Create new endpoint configuration to subscribe to specific webhook events.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "default": true, + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + } + }, + "signing_secret": { + "type": "string", + "minLength": 8, + "maxLength": 64, + "description": "Secret key to use when signing. All events will be signed according to the [Standard Webhooks](https://www.standardwebhooks.com/) specification." + } + }, + "required": ["url", "event_types", "signing_secret"] + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + } + } + } + } + }, + "delete": { + "operationId": "v2-organizations-slug-webhooks-endpoints-delete", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string", + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba" + }, + "required": true, + "description": "Organization slug" + } + ], + "tags": ["Organization webhooks"], + "responses": { + "200": { + "description": "Deleted endpoints", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Delete all endpoints", + "description": "Delete all endpoints including all events and deliveries.\n\nAny in-flight webhooks will result in a no-op." + } + }, + "/v2/organizations/{slug}/webhooks/endpoints/{id}": { + "get": { + "operationId": "v2-organizations-slug-webhooks-endpoints-id-get", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string", + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba" + }, + "required": true, + "description": "Organization slug" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of an endpoint (UUID v7)." + } + ], + "tags": ["Organization webhooks"], + "responses": { + "200": { + "description": "Endpoint details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "EndpointNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_not_found" + }, + "message": { + "type": "string", + "const": "Webhook endpoint not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "endpoint_not_found", + "message": "Webhook endpoint not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Get endpoint", + "description": "Get details of a specific endpoint." + }, + "patch": { + "operationId": "v2-organizations-slug-webhooks-endpoints-id-patch", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string", + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba" + }, + "required": true, + "description": "Organization slug" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of an endpoint (UUID v7)." + } + ], + "tags": ["Organization webhooks"], + "responses": { + "200": { + "description": "Updated endpoint details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "EndpointNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_not_found" + }, + "message": { + "type": "string", + "const": "Webhook endpoint not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "endpoint_not_found", + "message": "Webhook endpoint not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Update endpoint", + "description": "Update endpoint's configuration.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + } + }, + "signing_secret": { + "type": "string", + "minLength": 8, + "maxLength": 64, + "description": "Secret key to use when signing. All events will be signed according to the [Standard Webhooks](https://www.standardwebhooks.com/) specification." + } + } + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + } + } + } + } + }, + "delete": { + "operationId": "v2-organizations-slug-webhooks-endpoints-id-delete", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string", + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba" + }, + "required": true, + "description": "Organization slug" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of an endpoint (UUID v7)." + } + ], + "tags": ["Organization webhooks"], + "responses": { + "200": { + "description": "Deleted endpoint details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "EndpointNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_not_found" + }, + "message": { + "type": "string", + "const": "Webhook endpoint not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "endpoint_not_found", + "message": "Webhook endpoint not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Delete endpoint", + "description": "Delete the endpoint including all events and deliveries\n\nAny in-flight webhooks will result in a no-op." + } + }, + "/v2/organizations/{slug}/webhooks/endpoints/{id}/deliveries": { + "get": { + "operationId": "v2-organizations-slug-webhooks-endpoints-id-deliveries-get", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string", + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba" + }, + "required": true, + "description": "Organization slug" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of an endpoint (UUID v7)." + }, + { + "in": "query", + "name": "page[before]", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "description": "Cursor in cursor-based pagination to return up to `page[size]` records before (exclusive) the entry specified by this query param." + }, + { + "in": "query", + "name": "page[after]", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "description": "Cursor in cursor-based pagination to return up to `page[size]` records after (exclusive) the entry specified by this query param." + }, + { + "in": "query", + "name": "page[size]", + "schema": { + "default": "20", + "type": "string", + "pattern": "^\\d+$" + }, + "description": "Up to how many records to return." + } + ], + "tags": ["Organization webhooks"], + "responses": { + "200": { + "description": "List of deliveries", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "delivery", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of a delivery (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of a delivery (UUID v7)." + }, + "event_id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of the event, which triggered the delivery (UUID v7)." + }, + "status": { + "type": "string", + "enum": ["pending", "success", "failure", "skipped"], + "description": "Status of the delivery attempt." + }, + "response_code": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "HTTP status code of the response, `0` if unavailable." + }, + "response_headers": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "HTTP headers of the response, `{}` if unavailable." + }, + "response_body": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "Parsed JSON representation of an HTTP body of the response, `{}` if unavailable." + }, + { + "type": "string", + "description": "String representation of an HTTP body of the response." + } + ] + }, + { + "type": "null" + } + ] + }, + "attempt_timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of the attempt." + } + }, + "required": [ + "id", + "event_id", + "status", + "response_code", + "response_headers", + "response_body", + "attempt_timestamp" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "links": { + "type": "object", + "properties": { + "first": { + "description": "URL path to the first page if available.", + "example": "/v2/organizations/slug/webhooks/endpoints/{id}/deliveries?page[size]=10", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "prev": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "URL path to the previous page.", + "example": "/v2/organizations/slug/webhooks/endpoints/{id}/deliveries?page[size]=10&page[before]=019adf7d-4513-74c5-bb9a-f1bc0f7a95d7" + }, + "next": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "URL path to the next page.", + "example": "/v2/organizations/slug/webhooks/endpoints/{id}/deliveries?page[size]=10&page[after]=019adf7d-4513-7062-b292-78b86cc470a4" + }, + "last": { + "description": "URL path to the last page if available.", + "example": "/v2/organizations/slug/webhooks/endpoints/{id}/deliveries?page[size]=10&page[after]=019adf7d-4513-71ba-b264-21900edb4295", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["prev", "next"] + } + }, + "required": ["data", "links"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "EndpointNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_not_found" + }, + "message": { + "type": "string", + "const": "Webhook endpoint not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "endpoint_not_found", + "message": "Webhook endpoint not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "List deliveries", + "description": "List all deliveries for a specific endpoint in descending order (newest first).\n\nDeliveries which has expired are no longer available and will not be listed." + } + }, + "/v2/organizations/{slug}/webhooks/endpoints/{id}/test": { + "post": { + "operationId": "v2-organizations-slug-webhooks-endpoints-id-test-post", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string", + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba" + }, + "required": true, + "description": "Organization slug" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of an endpoint (UUID v7)." + } + ], + "tags": ["Organization webhooks"], + "responses": { + "201": { + "description": "Event published", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "ingress", + "description": "Resource type." + }, + "id": { + "type": "string" + } + }, + "required": ["type", "id"] + } + }, + "required": ["data"] + } + } + } + }, + "400": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_disabled" + }, + "message": { + "type": "string", + "const": "Webhook endpoint is disabled" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "EndpointTestDisabled" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_event_type_not_subscribed" + }, + "message": { + "type": "string", + "const": "Webhook endpoint is not subscribed to this event type" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "EndpointTestWrongEventType" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "EndpointTestDisabled": { + "value": { + "error": { + "code": "endpoint_disabled", + "description": "Endpoint is disabled, to send test event endpoint must first be enabled.", + "message": "Webhook endpoint is disabled" + } + } + }, + "EndpointTestWrongEventType": { + "value": { + "error": { + "code": "endpoint_event_type_not_subscribed", + "description": "Only event types that the endpoint is subscribed to can be specified.", + "message": "Webhook endpoint is not subscribed to this event type" + } + } + } + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "EndpointNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_not_found" + }, + "message": { + "type": "string", + "const": "Webhook endpoint not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "endpoint_not_found", + "message": "Webhook endpoint not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Send test event", + "description": "Ingests and schedules a test webhook event to be published out this endpoints.\n\nWhich event type to use can be specified in the request body, otherwise\nit will use any matching type the endpoint is listening for.\n\nThe event will contain `is_test: true` in it's payload.\n\nThis endpoint is heavy rate-limited to allow for 10 request within 60 seconds.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "event", + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "type": { + "description": "Webhook event type.", + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "project.v1.paused", + "project.v1.created", + "project.v1.restored", + "project.v1.transferred", + "project.v1.removed", + "project.v1.restarted", + "project.v1.status.changed", + "project.v1.backup.started", + "project.v1.branch.created", + "project.v1.branch.updated", + "project.v1.branch.removed", + "organization.v1.member.invitation.created", + "organization.v1.member.invitation.canceled", + "organization.v1.member.added", + "organization.v1.member.removed", + "organization.v1.member.role.assigned", + "organization.v1.member.role.removed", + "organization.v1.member.role.updated", + "organization.v1.billing.plan.upgraded", + "organization.v1.billing.plan.downgraded", + "project.v1.branch.deleted" + ] + } + }, + "required": ["type"] + } + }, + "required": ["type", "attributes"] + } + } + } + } + } + } + } + }, + "/v2/organizations/{slug}/webhooks/deliveries/{id}": { + "get": { + "operationId": "v2-organizations-slug-webhooks-deliveries-id-get", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string", + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba" + }, + "required": true, + "description": "Organization slug" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of a delivery (UUID v7)." + } + ], + "tags": ["Organization webhooks"], + "responses": { + "200": { + "description": "Delivery details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "delivery", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of a delivery (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of a delivery (UUID v7)." + }, + "event_id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of the event, which triggered the delivery (UUID v7)." + }, + "status": { + "type": "string", + "enum": ["pending", "success", "failure", "skipped"], + "description": "Status of the delivery attempt." + }, + "response_code": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "HTTP status code of the response, `0` if unavailable." + }, + "response_headers": { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + } + }, + { + "type": "null" + } + ], + "description": "HTTP headers of the response, `{}` if unavailable." + }, + "response_body": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "Parsed JSON representation of an HTTP body of the response, `{}` if unavailable." + }, + { + "type": "string", + "description": "String representation of an HTTP body of the response." + } + ] + }, + { + "type": "null" + } + ] + }, + "attempt_timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of the attempt." + }, + "event": { + "type": "object", + "properties": { + "type": { + "description": "Webhook event type.", + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "project.v1.paused", + "project.v1.created", + "project.v1.restored", + "project.v1.transferred", + "project.v1.removed", + "project.v1.restarted", + "project.v1.status.changed", + "project.v1.backup.started", + "project.v1.branch.created", + "project.v1.branch.updated", + "project.v1.branch.removed", + "organization.v1.member.invitation.created", + "organization.v1.member.invitation.canceled", + "organization.v1.member.added", + "organization.v1.member.removed", + "organization.v1.member.role.assigned", + "organization.v1.member.role.removed", + "organization.v1.member.role.updated", + "organization.v1.billing.plan.upgraded", + "organization.v1.billing.plan.downgraded", + "project.v1.branch.deleted" + ] + }, + "payload": { + "type": "object", + "properties": { + "organization_slug": { + "type": "string", + "pattern": "^[\\w-]+$", + "description": "Organization slug", + "example": "tsrqponmlkjihgfedcba" + }, + "project_ref": { + "anyOf": [ + { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "description": "Project ref", + "example": "abcdefghijklmnopqrst" + }, + { + "type": "null" + } + ] + } + }, + "required": ["organization_slug", "project_ref"], + "description": "Final data sent to the consumer." + }, + "timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of event publication." + } + }, + "required": ["type", "payload", "timestamp"] + } + }, + "required": [ + "id", + "event_id", + "status", + "response_code", + "response_headers", + "response_body", + "attempt_timestamp", + "event" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "DeliveryNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "delivery_not_found" + }, + "message": { + "type": "string", + "const": "Webhook delivery not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "delivery_not_found", + "message": "Webhook delivery not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Get delivery", + "description": "Get details of a specific delivery attempt." + } + }, + "/v2/organizations/{slug}/webhooks/deliveries/{id}/retry": { + "post": { + "operationId": "v2-organizations-slug-webhooks-deliveries-id-retry-post", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string", + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba" + }, + "required": true, + "description": "Organization slug" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of a delivery (UUID v7)." + } + ], + "tags": ["Organization webhooks"], + "responses": { + "201": { + "description": "Delivery details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "ingress", + "description": "Resource type." + }, + "id": { + "type": "string" + } + }, + "required": ["type", "id"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "DeliveryNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "delivery_not_found" + }, + "message": { + "type": "string", + "const": "Webhook delivery not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "delivery_not_found", + "message": "Webhook delivery not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Retry delivery", + "description": "Retry delivering the same event again.\n\nAutomatic retries are not applicable to manual retries - if the delivery fails, there won't be any automatic retries attempted.\n\nThis endpoint is heavy rate-limited to allow for 10 request within 60 seconds." + } } }, "components": { @@ -15520,7 +32438,8 @@ "type": "string" } }, - "required": ["id", "username"] + "required": ["id", "username"], + "nullable": true }, "updated_by": { "type": "object", @@ -15532,7 +32451,8 @@ "type": "string" } }, - "required": ["id", "username"] + "required": ["id", "username"], + "nullable": true }, "favorite": { "type": "boolean" @@ -15608,7 +32528,8 @@ "type": "string" } }, - "required": ["id", "username"] + "required": ["id", "username"], + "nullable": true }, "updated_by": { "type": "object", @@ -15620,7 +32541,8 @@ "type": "string" } }, - "required": ["id", "username"] + "required": ["id", "username"], + "nullable": true }, "favorite": { "type": "boolean" @@ -15640,7 +32562,8 @@ "type": "string" } }, - "required": ["sql"] + "required": ["sql"], + "nullable": true } }, "required": [ @@ -18857,6 +35780,7 @@ "mfa_phone_template", "mfa_phone_max_frequency", "nimbus_oauth_client_id", + "nimbus_oauth_email_optional", "nimbus_oauth_client_secret", "password_hibp_enabled", "password_min_length", @@ -21530,8 +38454,7 @@ "type": "string" }, "import_map_path": { - "type": "string", - "nullable": true + "type": "string" }, "ezbr_sha256": { "type": "string" @@ -21672,8 +38595,7 @@ "type": "string" }, "import_map_path": { - "type": "string", - "nullable": true + "type": "string" }, "ezbr_sha256": { "type": "string" @@ -21773,8 +38695,7 @@ "type": "string" }, "import_map_path": { - "type": "string", - "nullable": true + "type": "string" }, "ezbr_sha256": { "type": "string" @@ -21825,8 +38746,7 @@ "type": "string" }, "import_map_path": { - "type": "string", - "nullable": true + "type": "string" }, "ezbr_sha256": { "type": "string" @@ -28778,6 +45698,169 @@ } }, "required": ["data", "links"] + }, + "APIErrorObject": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "enum": [ + "bad_request", + "forbidden", + "internal_server_error", + "not_found", + "payment_required", + "rate_limit_exceeded", + "request_timeout", + "request_too_large", + "unauthorized", + "validation_failed", + "agentic_credential_not_found", + "branch_admin_required", + "compute_build_context_too_large", + "compute_build_in_progress", + "compute_deploy_rate_limit_exceeded", + "compute_instance_limit_exceeded", + "compute_instance_not_found", + "compute_not_enabled", + "compute_request_rejected", + "compute_unavailable", + "notebook_cell_duplicate", + "notebook_cell_unknown", + "notebook_disabled", + "notebook_not_found", + "notebook_sort_invalid", + "organization_invitation_email_delivery_failed", + "organization_invitations_partially_failed", + "organization_role_name_invalid", + "organization_role_not_entitled", + "pagination_cursor_invalid", + "project_branch_resync_unsupported", + "project_not_found", + "project_privatelink_disabled", + "project_transfer_disabled", + "delivery_not_found", + "endpoint_disabled", + "endpoint_event_type_not_subscribed", + "endpoint_not_found", + "endpoint_url_unresolvable", + "webhooks_access_disabled" + ] + }, + "message": { + "type": "string" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"] + } + } + }, + "required": ["code", "message"] } } } diff --git a/packages/api/src/internal/client.ts b/packages/api/src/internal/client.ts index bbd8668d50..7a4d3a0855 100644 --- a/packages/api/src/internal/client.ts +++ b/packages/api/src/internal/client.ts @@ -1,4 +1,4 @@ -import { Effect, Option, Context } from "effect"; +import { Clock, Data, Duration, Effect, Option, Context, Random, Schedule } from "effect"; import * as Cause from "effect/Cause"; import * as Redacted from "effect/Redacted"; import type { SchemaError } from "effect/Schema"; @@ -32,11 +32,34 @@ interface ResolvedSupabaseApiConfig { readonly headers?: Readonly> | undefined; } +/** Per-request overrides of the construction-time base URL, bearer token, headers, and retry policy. */ +export interface SupabaseApiRequestOptions { + readonly baseUrl?: string | undefined; + readonly accessToken?: string | Redacted.Redacted | undefined; + readonly headers?: Readonly> | undefined; + readonly retry?: SupabaseApiRetryOptions | undefined; +} + +/** + * Retry policy for Management API requests. Failed attempts back off exponentially with full + * jitter and honour `Retry-After` on any retried response. + */ export interface SupabaseApiRetryOptions { + /** Retries after the first attempt; defaults to 5. */ readonly maxRetries?: number | undefined; + /** Timeout of each attempt in milliseconds; defaults to 60 seconds, `Infinity` disables it. */ readonly requestTimeoutMs?: number | undefined; } +/** + * Request options for `executeRaw`. `extraBody` fields are forwarded unvalidated, for + * read-modify-write of server state the spec does not fully describe. + */ +export interface SupabaseApiRawRequestOptions extends SupabaseApiRequestOptions { + /** Extra JSON body fields; fields the operation's schema declares take precedence. */ + readonly extraBody?: Readonly> | undefined; +} + export interface SupabaseApiClientOptions { readonly retry?: SupabaseApiRetryOptions | undefined; readonly transformClient?: @@ -54,6 +77,7 @@ export interface SupabaseApiClientShape { readonly execute: ( definition: OperationDefinition, input: OperationInput, + options?: SupabaseApiRequestOptions, ) => Effect.Effect, SupabaseApiError>; /** * Executes an operation but returns the raw HTTP response, without decoding @@ -65,7 +89,7 @@ export interface SupabaseApiClientShape { readonly executeRaw: ( definition: OperationDefinition, input: OperationInput, - headers?: Readonly>, + options?: SupabaseApiRawRequestOptions, ) => Effect.Effect; } @@ -184,6 +208,13 @@ function revealRedactedValue(value: unknown): unknown { return value; } +const DEFAULT_MAX_RETRIES = 5; +const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; +const RETRY_BASE_DELAY = Duration.millis(250); +const RETRY_MAX_DELAY = Duration.seconds(4); +/** Longest `Retry-After` honoured; a longer wait returns the response to the caller instead of retrying. */ +const RETRY_AFTER_MAX_DELAY = Duration.seconds(10); + function isIdempotentMethod(method: string): boolean { return method === "GET" || method === "PUT" || method === "DELETE" || method === "HEAD"; } @@ -192,9 +223,76 @@ function isRetryableTransportError(error: unknown): error is HttpClientError.Htt return HttpClientError.isHttpClientError(error) && error.reason._tag === "TransportError"; } +/** A 429 means the server did not process the request, so every method may be resent. */ function isRetryableResponse(response: HttpClientResponse.HttpClientResponse): boolean { return ( - response.status >= 500 && response.status <= 599 && isIdempotentMethod(response.request.method) + response.status === 429 || + (response.status >= 500 && + response.status <= 599 && + isIdempotentMethod(response.request.method)) + ); +} + +/** A retryable response travelling the error channel so the retry schedule can inspect it. */ +class RetryableResponse extends Data.TaggedError("RetryableResponse")<{ + readonly response: HttpClientResponse.HttpClientResponse; +}> {} + +type RetryableFailure = HttpClientError.HttpClientError | RetryableResponse; + +/** Parses a `Retry-After` value, either delta-seconds or an HTTP-date, into a wait. */ +function parseRetryAfter( + value: string | undefined, + nowMillis: number, +): Option.Option { + const trimmed = value?.trim(); + if (trimmed === undefined || trimmed === "") { + return Option.none(); + } + if (/^\d+$/.test(trimmed)) { + return Option.some(Duration.seconds(Number(trimmed))); + } + // HTTP-dates start with a day name; this keeps `Date.parse` from reading bare numbers as years. + const dateMillis = /^[A-Za-z]/.test(trimmed) ? Date.parse(trimmed) : Number.NaN; + return Number.isNaN(dateMillis) + ? Option.none() + : Option.some(Duration.millis(Math.max(0, dateMillis - nowMillis))); +} + +function retryAfterOf( + failure: RetryableFailure, + nowMillis: number, +): Option.Option { + if (failure._tag !== "RetryableResponse") { + return Option.none(); + } + return parseRetryAfter(failure.response.headers["retry-after"], nowMillis); +} + +function exceedsRetryAfterCap( + response: HttpClientResponse.HttpClientResponse, + nowMillis: number, +): boolean { + return Option.exists( + parseRetryAfter(response.headers["retry-after"], nowMillis), + Duration.isGreaterThan(RETRY_AFTER_MAX_DELAY), + ); +} + +function makeRetrySchedule(maxRetries: number) { + return Schedule.exponential(RETRY_BASE_DELAY).pipe( + Schedule.setInputType(), + Schedule.modifyDelay(({ input, duration, now }) => + Option.match(retryAfterOf(input, now), { + onSome: Effect.succeed, + onNone: () => + Effect.map(Random.next, (random) => + Duration.times(Duration.min(duration, RETRY_MAX_DELAY), random), + ), + }), + ), + Schedule.upTo({ times: maxRetries }), + Schedule.tap(({ attempt }) => Effect.annotateCurrentSpan("http.request.resend_count", attempt)), ); } @@ -202,16 +300,21 @@ function applySupabaseRetryPolicy( client: HttpClient.HttpClient, options?: SupabaseApiRetryOptions, ): HttpClient.HttpClient { - const maxRetries = options?.maxRetries ?? 5; - const timeoutMs = options?.requestTimeoutMs ?? 60_000; - - return HttpClient.transform(client, (requestEffect, request) => { - const retriesTransportErrors = isIdempotentMethod(request.method); - const attempt = ( - retries: number, - ): Effect.Effect => - requestEffect.pipe( - Effect.timeout(timeoutMs), + return HttpClient.transform(client, (requestEffect, request) => + Effect.flatMap(Effect.service(RequestOverrides), (overrides) => { + const timeoutMs = + overrides?.retry?.requestTimeoutMs ?? + options?.requestTimeoutMs ?? + DEFAULT_REQUEST_TIMEOUT_MS; + const maxRetries = overrides?.retry?.maxRetries ?? options?.maxRetries ?? DEFAULT_MAX_RETRIES; + const retriesTransportErrors = isIdempotentMethod(request.method); + const attempt: Effect.Effect< + HttpClientResponse.HttpClientResponse, + HttpClientError.HttpClientError | Cause.TimeoutError, + never + > = Number.isFinite(timeoutMs) ? Effect.timeout(requestEffect, timeoutMs) : requestEffect; + + return attempt.pipe( Effect.catchIf(Cause.isTimeoutError, (error) => Effect.fail( new HttpClientError.HttpClientError({ @@ -223,20 +326,60 @@ function applySupabaseRetryPolicy( }), ), ), - Effect.catchIf(isRetryableTransportError, (error) => - retries < maxRetries && retriesTransportErrors - ? attempt(retries + 1) - : Effect.fail(error), - ), Effect.flatMap((response) => - isRetryableResponse(response) && retries < maxRetries - ? attempt(retries + 1) - : Effect.succeed(response), + Effect.flatMap(Clock.currentTimeMillis, (nowMillis) => + isRetryableResponse(response) && !exceedsRetryAfterCap(response, nowMillis) + ? Effect.fail(new RetryableResponse({ response })) + : Effect.succeed(response), + ), ), + Effect.retry({ + schedule: makeRetrySchedule(maxRetries), + while: (failure) => + failure._tag === "RetryableResponse" || + (retriesTransportErrors && isRetryableTransportError(failure)), + }), + Effect.catchTag("RetryableResponse", (failure) => Effect.succeed(failure.response)), ); + }), + ); +} - return attempt(0); - }); +/** + * Carries one request's overrides to the innermost client layer, so the client + * handed to `transformClient` stays fully configured and overridden requests + * still pass through it, the retry policy, and the timeout. + */ +const RequestOverrides = Context.Reference( + "@supabase/api/RequestOverrides", + { defaultValue: () => undefined }, +); + +function applyRequestDefaults( + request: HttpClientRequest.HttpClientRequest, + config: ResolvedSupabaseApiConfig, + overrides: SupabaseApiRequestOptions | undefined, +): HttpClientRequest.HttpClientRequest { + const accessToken = overrides?.accessToken ?? config.accessToken; + let next = HttpClientRequest.prependUrl(request, overrides?.baseUrl ?? config.baseUrl); + next = HttpClientRequest.setHeader( + next, + "Authorization", + `Bearer ${String(revealRedactedValue(accessToken))}`, + ); + next = HttpClientRequest.setHeader( + next, + "User-Agent", + config.userAgent ?? "supabase-api/unknown", + ); + for (const headers of [config.headers, overrides?.headers]) { + for (const [name, value] of Object.entries(headers ?? {})) { + if (value !== undefined) { + next = HttpClientRequest.setHeader(next, name, value); + } + } + } + return next; } function prepareClient( @@ -245,27 +388,11 @@ function prepareClient( options?: SupabaseApiClientOptions, ): Effect.Effect { const prefixed = client.pipe( - HttpClient.mapRequest((request) => { - let next = HttpClientRequest.prependUrl(request, config.baseUrl); - next = HttpClientRequest.setHeader( - next, - "Authorization", - `Bearer ${String(revealRedactedValue(config.accessToken))}`, - ); - next = HttpClientRequest.setHeader( - next, - "User-Agent", - config.userAgent ?? "supabase-api/unknown", - ); - if (config.headers !== undefined) { - for (const [name, value] of Object.entries(config.headers)) { - if (value !== undefined) { - next = HttpClientRequest.setHeader(next, name, value); - } - } - } - return next; - }), + HttpClient.mapRequestEffect((request) => + Effect.map(Effect.service(RequestOverrides), (overrides) => + applyRequestDefaults(request, config, overrides), + ), + ), // Trace context stays local; no API consumer reads it. HttpClient.transformResponse(Effect.provideService(HttpClient.TracerPropagationEnabled, false)), ); @@ -406,13 +533,18 @@ function encodeBody( request: HttpClientRequest.HttpClientRequest, definition: OperationDefinition, input: object, + extraBody: Readonly> | undefined, ): Effect.Effect { + if (extraBody !== undefined && definition.requestBody.kind !== "json") { + return Effect.die(`${definition.id} has no JSON field body to extend with extraBody.`); + } + if (definition.requestBody.kind === "none") { return Effect.succeed(request); } if (definition.requestBody.kind === "json") { - const payload: Record = {}; + const payload: Record = { ...extraBody }; for (const field of definition.requestBody.fields) { const value = Reflect.get(input, field); if (value !== undefined) { @@ -451,6 +583,7 @@ function encodeBody( function buildRequest( definition: OperationDefinition, input: object, + extraBody?: Readonly>, ): Effect.Effect { const path = interpolatePath(definition.path, definition.pathParams, input); let request = HttpClientRequest.make(definition.method)(path); @@ -490,17 +623,20 @@ function buildRequest( } } - return encodeBody(request, definition, input); + return encodeBody(request, definition, input, extraBody); } function executeRequest( client: HttpClient.HttpClient, definition: OperationDefinition, input: object, + options: SupabaseApiRequestOptions | undefined, ): Effect.Effect { return Effect.gen(function* () { const request = yield* buildRequest(definition, input); - const response = yield* client.execute(request); + const response = yield* client + .execute(request) + .pipe(Effect.provideService(RequestOverrides, options)); return yield* HttpClientResponse.filterStatusOk(response); }).pipe(Effect.withSpan(definition.id, { attributes: { "api.operation": definition.id } })); } @@ -569,14 +705,20 @@ export function makeSupabaseApiClient( ); const httpClient = yield* HttpClient.HttpClient; const prepared = yield* prepareClient(httpClient, resolvedConfig, options); + // Excess keys fail instead of being stripped so a caller typo or stale field + // never silently drops out of the request. + const decodeInput = (definition: OperationDefinition, input: unknown) => + Schema.decodeUnknownEffect(definition.inputSchema)(input, { + onExcessProperty: "error", + }).pipe( + Effect.mapError((error) => new SupabaseApiInputError(error.message, { cause: error })), + ); return { - execute: (definition, input) => + execute: (definition, input, requestOptions) => Effect.gen(function* () { - const validated = yield* Schema.decodeUnknownEffect(definition.inputSchema)(input).pipe( - Effect.mapError((error) => new SupabaseApiInputError(error.message, { cause: error })), - ); - const response = yield* executeRequest(prepared, definition, validated); + const validated = yield* decodeInput(definition, input); + const response = yield* executeRequest(prepared, definition, validated, requestOptions); if (isJsonOperation(definition)) { return yield* decodeJsonResponse(definition, response); } @@ -588,17 +730,13 @@ export function makeSupabaseApiClient( } return yield* Effect.die(`Unsupported response kind: ${definition.response.kind}`); }), - executeRaw: (definition, input, headers) => + executeRaw: (definition, input, requestOptions) => Effect.gen(function* () { - const validated = yield* Schema.decodeUnknownEffect(definition.inputSchema)(input).pipe( - Effect.mapError((error) => new SupabaseApiInputError(error.message, { cause: error })), - ); - const request = yield* buildRequest(definition, validated).pipe( - Effect.map((request) => - headers === undefined ? request : HttpClientRequest.setHeaders(request, headers), - ), - ); - return yield* prepared.execute(request); + const validated = yield* decodeInput(definition, input); + const request = yield* buildRequest(definition, validated, requestOptions?.extraBody); + return yield* prepared + .execute(request) + .pipe(Effect.provideService(RequestOverrides, requestOptions)); }).pipe(Effect.withSpan(definition.id, { attributes: { "api.operation": definition.id } })), }; }); diff --git a/packages/api/src/internal/client.unit.test.ts b/packages/api/src/internal/client.unit.test.ts index 1943eac670..dbd83aa270 100644 --- a/packages/api/src/internal/client.unit.test.ts +++ b/packages/api/src/internal/client.unit.test.ts @@ -1,10 +1,22 @@ import { describe, expect, test } from "vitest"; -import { Effect, Exit, Layer, Option, Redacted } from "effect"; +import { + Cause, + Clock, + Duration, + Effect, + Exit, + Fiber, + Layer, + Option, + Random, + Redacted, +} from "effect"; +import { TestClock } from "effect/testing"; import * as HttpBody from "effect/http/HttpBody"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import type * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as UrlParams from "effect/http/UrlParams"; import * as Schema from "effect/Schema"; @@ -12,6 +24,9 @@ import { operationDefinitions } from "../generated/contracts.ts"; import { makeSupabaseApiClient, markSupabaseApiInputErrorAsUserInput, + type SupabaseApiClientOptions, + type SupabaseApiClientShape, + type SupabaseApiRetryOptions, SupabaseApiInputError, } from "./client.ts"; @@ -159,6 +174,60 @@ const config = { userAgent: "supabase-api/test", } as const; +/** Runs `effect` on a virtual clock with every retry jitter draw fixed at `jitter`. */ +function onVirtualClock( + effect: Effect.Effect, + jitter = 0.5, +): Promise { + return Effect.runPromise( + effect.pipe( + Effect.provideService(Random.Random, { + nextIntUnsafe: () => 0, + nextDoubleUnsafe: () => jitter, + }), + Effect.provide(TestClock.layer()), + ), + ); +} + +/** + * Starts one client call against a stub transport on the virtual clock and records the clock + * reading of every request the transport receives. + */ +function startVirtualCall( + call: (client: SupabaseApiClientShape) => Effect.Effect, + respond: ( + request: HttpClientRequest.HttpClientRequest, + attempt: number, + ) => HttpClientResponse.HttpClientResponse | HttpClientError.HttpClientError, + options?: SupabaseApiClientOptions, +) { + return Effect.gen(function* () { + const requestTimes: Array = []; + const fiber = yield* makeSupabaseApiClient(config, options).pipe( + Effect.flatMap(call), + Effect.exit, + Effect.provide( + httpClientLayer((request) => + Effect.gen(function* () { + requestTimes.push(yield* Clock.currentTimeMillis); + const outcome = respond(request, requestTimes.length); + return yield* HttpClientError.isHttpClientError(outcome) + ? Effect.fail(outcome) + : Effect.succeed(outcome); + }), + ), + ), + Effect.forkChild, + ); + yield* Effect.yieldNow; + return { requestTimes, fiber }; + }); +} + +const listProjects = (client: SupabaseApiClientShape) => + client.execute<"v1ListAllProjects">(operationDefinitions.v1ListAllProjects, {}); + describe("makeSupabaseApiClient", () => { test("defaults request-schema failures to generated-client provenance", async () => { let requests = 0; @@ -206,6 +275,142 @@ describe("makeSupabaseApiClient", () => { expect(requests).toBe(0); }); + test("rejects unknown request keys at any depth before sending a request", async () => { + let requests = 0; + const client = await Effect.runPromise( + makeSupabaseApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + requests += 1; + return Effect.succeed(jsonResponse(request, 200, {})); + }), + ), + ), + ); + const ref = "abcdefghijklmnopqrst"; + const topLevelExcess = { ref, requestedConfig: { database: true }, junk: 1 }; + const nestedExcess = { ref, requestedConfig: { database: true, junk: 1 } }; + + const errors = await Promise.all([ + Effect.runPromise( + client + .execute(operationDefinitions.v1UpdateSslEnforcementConfig, topLevelExcess) + .pipe(Effect.flip), + ), + Effect.runPromise( + client + .executeRaw(operationDefinitions.v1UpdateSslEnforcementConfig, nestedExcess) + .pipe(Effect.flip), + ), + ]); + + for (const error of errors) { + expect(error).toBeInstanceOf(SupabaseApiInputError); + } + expect(errors[0]?.message).toContain('["junk"]'); + expect(errors[1]?.message).toContain('["requestedConfig"]["junk"]'); + expect(requests).toBe(0); + }); + + test("per-request options override the base URL, token, and headers for that request only", async () => { + const seen: Array<{ + url: string; + authorization: string | undefined; + userAgent: string | undefined; + custom: string | undefined; + }> = []; + const client = await Effect.runPromise( + makeSupabaseApiClient({ ...config, headers: { "X-Custom": "default" } }).pipe( + Effect.provide( + httpClientLayer((request) => { + seen.push({ + url: request.url, + authorization: request.headers.authorization, + userAgent: request.headers["user-agent"], + custom: request.headers["x-custom"], + }); + return Effect.succeed(jsonResponse(request, 200, [])); + }), + ), + ), + ); + + await Effect.runPromise( + client.execute( + operationDefinitions.v1ListAllProjects, + {}, + { + baseUrl: "https://api.example.test/prefix", + accessToken: Redacted.make("override-token"), + headers: { "X-Custom": "override" }, + }, + ), + ); + await Effect.runPromise( + client.executeRaw( + operationDefinitions.v1ListAllProjects, + {}, + { baseUrl: "https://raw.example.test", accessToken: "raw-token" }, + ), + ); + await Effect.runPromise(client.execute(operationDefinitions.v1ListAllProjects, {})); + + expect(seen).toEqual([ + { + url: "https://api.example.test/prefix/v1/projects", + authorization: "Bearer override-token", + userAgent: "supabase-api/test", + custom: "override", + }, + { + url: "https://raw.example.test/v1/projects", + authorization: "Bearer raw-token", + userAgent: "supabase-api/test", + custom: "default", + }, + { + url: "https://api.supabase.com/v1/projects", + authorization: "Bearer test-token", + userAgent: "supabase-api/test", + custom: "default", + }, + ]); + }); + + test("requests with per-request overrides still pass through transformClient", async () => { + const transformed: Array = []; + const wire: Array<{ url: string; stamp: string | undefined }> = []; + const client = await Effect.runPromise( + makeSupabaseApiClient(config, { + transformClient: (inner) => + Effect.succeed( + HttpClient.mapRequest(inner, (request) => { + transformed.push(request.url); + return HttpClientRequest.setHeader(request, "X-Stamp", "stamped"); + }), + ), + }).pipe( + Effect.provide( + httpClientLayer((request) => { + wire.push({ url: request.url, stamp: request.headers["x-stamp"] }); + return Effect.succeed(jsonResponse(request, 200, [])); + }), + ), + ), + ); + + await Effect.runPromise( + client.execute( + operationDefinitions.v1ListAllProjects, + {}, + { baseUrl: "https://api.example.test" }, + ), + ); + + expect(transformed).toEqual(["https://api.example.test/v1/projects"]); + expect(wire).toEqual([{ url: "https://api.example.test/v1/projects", stamp: "stamped" }]); + }); + test("fails request-body construction before sending a request", async () => { class BrokenBlob extends Blob { override arrayBuffer(): Promise { @@ -237,29 +442,67 @@ describe("makeSupabaseApiClient", () => { expect(requests).toBe(0); }); - test("retries transport errors for GET requests", async () => { - let attempts = 0; - - const result = await Effect.runPromise( + test("executeRaw forwards extraBody fields and lets validated fields win", async () => { + const bodies: unknown[] = []; + await Effect.runPromise( makeSupabaseApiClient(config).pipe( Effect.flatMap((client) => - client.execute<"v1ListAllProjects">(operationDefinitions.v1ListAllProjects, {}), + client.executeRaw( + operationDefinitions.v1UpdatePostgresConfig, + { ref: "abcdefghijklmnopqrst", effective_cache_size: "4GB" }, + { + extraBody: { + effective_cache_size: "1GB", + future_parameter: "on", + }, + }, + ), ), Effect.provide( httpClientLayer((request) => { - attempts += 1; - if (attempts < 3) { - return Effect.fail(transportError(request, "socket reset")); - } + bodies.push(JSON.parse(requestBodyText(request))); + return Effect.succeed(jsonResponse(request, 200, {})); + }), + ), + ), + ); + + expect(bodies).toEqual([{ effective_cache_size: "4GB", future_parameter: "on" }]); + }); + test("executeRaw rejects extraBody on operations without a JSON field body", async () => { + let requests = 0; + const exit = await Effect.runPromiseExit( + makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.executeRaw(operationDefinitions.v1ListAllProjects, {}, { extraBody: { a: 1 } }), + ), + Effect.provide( + httpClientLayer((request) => { + requests += 1; return Effect.succeed(jsonResponse(request, 200, [])); }), ), ), ); - expect(attempts).toBe(3); - expect(result).toEqual([]); + expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true); + expect(requests).toBe(0); + }); + + test("retries transport errors for GET requests", async () => { + const { requestTimes, result } = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request, attempt) => + attempt < 3 ? transportError(request, "socket reset") : jsonResponse(request, 200, []), + ); + yield* TestClock.adjust(Duration.minutes(1)); + return { requestTimes: call.requestTimes, result: yield* Fiber.join(call.fiber) }; + }), + ); + + expect(requestTimes).toHaveLength(3); + expect(result).toEqual(Exit.succeed([])); }); test("reveals redacted auth tokens only at the transport boundary", async () => { @@ -354,54 +597,6 @@ describe("makeSupabaseApiClient", () => { }); }); - test("retries 5xx responses for idempotent GET requests", async () => { - let attempts = 0; - - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1GetProject">(operationDefinitions.v1GetProject, { - ref: "abcdefghijklmnopqrst", - }), - ), - Effect.provide( - httpClientLayer((request) => { - attempts += 1; - if (attempts === 1) { - return Effect.succeed( - jsonResponse(request, 500, { - error: "temporary failure", - }), - ); - } - - return Effect.succeed( - jsonResponse(request, 200, { - id: "project-id", - ref: "abcdefghijklmnopqrst", - organization_id: "org-id", - organization_slug: "my-org", - name: "project-name", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - database: { - host: "db.supabase.internal", - version: "17.0.1", - postgres_engine: "17", - release_channel: "ga", - }, - }), - ); - }), - ), - ), - ); - - expect(attempts).toBe(2); - expect(result.database.host).toBe("db.supabase.internal"); - }); - test("decodes nullable JWT templates in API key responses", async () => { const result = await Effect.runPromise( makeSupabaseApiClient(config).pipe( @@ -735,28 +930,19 @@ describe("makeSupabaseApiClient", () => { }); test("stops after the configured number of transport retries", async () => { - let attempts = 0; - - const exit = await Effect.runPromise( - makeSupabaseApiClient(config, { - retry: { - maxRetries: 2, - }, - }).pipe( - Effect.flatMap((client) => - client.execute<"v1ListAllProjects">(operationDefinitions.v1ListAllProjects, {}), - ), - Effect.exit, - Effect.provide( - httpClientLayer((request) => { - attempts += 1; - return Effect.fail(transportError(request, "still broken")); - }), - ), - ), + const { requestTimes, exit } = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall( + listProjects, + (request) => transportError(request, "still broken"), + { retry: { maxRetries: 2 } }, + ); + yield* TestClock.adjust(Duration.minutes(1)); + return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) }; + }), ); - expect(attempts).toBe(3); + expect(requestTimes).toHaveLength(3); expect(Exit.isFailure(exit)).toBe(true); }); @@ -1228,3 +1414,330 @@ describe("makeSupabaseApiClient", () => { expect(requestUrlParam(seenRequest!, "filter")).toBeUndefined(); }); }); + +describe("retry policy", () => { + const createProject = (client: SupabaseApiClientShape) => + client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, { + db_pass: "hunter2", + name: "project-name", + organization_slug: "my-org", + }); + + const createdProject = (request: HttpClientRequest.HttpClientRequest) => + jsonResponse(request, 201, { + id: "project-id", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "my-org", + name: "project-name", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + }); + + const gaps = (times: ReadonlyArray) => times.slice(1).map((time, i) => time - times[i]!); + + test.each([500, 503])( + "waits out the backoff before retrying a GET after a %i", + async (status) => { + const { early, afterBackoff, exit } = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request, attempt) => + attempt === 1 ? jsonResponse(request, status, {}) : jsonResponse(request, 200, []), + ); + // The first backoff ceiling is 250ms, so a jitter draw of 0.5 waits 125ms. + yield* TestClock.adjust(Duration.millis(124)); + const early = call.requestTimes.length; + yield* TestClock.adjust(Duration.millis(1)); + const afterBackoff = call.requestTimes.length; + return { early, afterBackoff, exit: yield* Fiber.join(call.fiber) }; + }), + ); + + expect(early).toBe(1); + expect(afterBackoff).toBe(2); + expect(exit).toEqual(Exit.succeed([])); + }, + ); + + test("backs off exponentially up to the delay cap", async () => { + const requestTimes = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall( + listProjects, + (request) => jsonResponse(request, 503, {}), + { retry: { maxRetries: 8 } }, + ); + yield* TestClock.adjust(Duration.minutes(1)); + yield* Fiber.join(call.fiber); + return call.requestTimes; + }), + 1, + ); + + expect(gaps(requestTimes)).toEqual([250, 500, 1000, 2000, 4000, 4000, 4000, 4000]); + }); + + test("retries a POST after a 429 only once its Retry-After seconds have elapsed", async () => { + const { early, afterWait, exit } = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(createProject, (request, attempt) => + attempt === 1 + ? HttpClientResponse.fromWeb( + request, + new Response("{}", { status: 429, headers: { "retry-after": "2" } }), + ) + : createdProject(request), + ); + yield* TestClock.adjust(Duration.millis(1999)); + const early = call.requestTimes.length; + yield* TestClock.adjust(Duration.millis(1)); + const afterWait = call.requestTimes.length; + return { early, afterWait, exit: yield* Fiber.join(call.fiber) }; + }), + ); + + expect(early).toBe(1); + expect(afterWait).toBe(2); + expect(Exit.isSuccess(exit)).toBe(true); + }); + + test("retries a GET immediately after a 500 with Retry-After 0", async () => { + const requestTimes = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request, attempt) => + attempt === 1 + ? HttpClientResponse.fromWeb( + request, + new Response("{}", { status: 500, headers: { "retry-after": "0" } }), + ) + : jsonResponse(request, 200, []), + ); + yield* TestClock.adjust(Duration.millis(0)); + yield* Fiber.join(call.fiber); + return call.requestTimes; + }), + ); + + expect(gaps(requestTimes)).toEqual([0]); + }); + + test("honours an HTTP-date Retry-After on a 503", async () => { + const now = Date.parse("2026-10-13T12:00:00Z"); + const requestTimes = await onVirtualClock( + Effect.gen(function* () { + yield* TestClock.setTime(now); + const call = yield* startVirtualCall(listProjects, (request, attempt) => + attempt === 1 + ? HttpClientResponse.fromWeb( + request, + new Response("{}", { + status: 503, + headers: { "retry-after": "Tue, 13 Oct 2026 12:00:03 GMT" }, + }), + ) + : jsonResponse(request, 200, []), + ); + yield* TestClock.adjust(Duration.minutes(1)); + yield* Fiber.join(call.fiber); + return call.requestTimes; + }), + ); + + expect(gaps(requestTimes)).toEqual([3000]); + }); + + test.each(["soon", "-5", ""])( + "falls back to the backoff when Retry-After is %j", + async (retryAfter) => { + const requestTimes = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request, attempt) => + attempt === 1 + ? HttpClientResponse.fromWeb( + request, + new Response("{}", { status: 429, headers: { "retry-after": retryAfter } }), + ) + : jsonResponse(request, 200, []), + ); + yield* TestClock.adjust(Duration.minutes(1)); + yield* Fiber.join(call.fiber); + return call.requestTimes; + }), + ); + + expect(gaps(requestTimes)).toEqual([125]); + }, + ); + + test("returns a 429 whose Retry-After exceeds the cap without retrying", async () => { + const { requestTimes, exit } = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request) => + HttpClientResponse.fromWeb( + request, + new Response("{}", { status: 429, headers: { "retry-after": "30" } }), + ), + ); + yield* TestClock.adjust(Duration.minutes(1)); + return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) }; + }), + ); + + expect(requestTimes).toHaveLength(1); + const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; + if (!HttpClientError.isHttpClientError(error) || error.reason._tag !== "StatusCodeError") { + throw new Error("expected StatusCodeError"); + } + expect(error.reason.response.status).toBe(429); + }); + + test("honours a Retry-After exactly at the cap", async () => { + const requestTimes = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request, attempt) => + attempt === 1 + ? HttpClientResponse.fromWeb( + request, + new Response("{}", { status: 429, headers: { "retry-after": "10" } }), + ) + : jsonResponse(request, 200, []), + ); + yield* TestClock.adjust(Duration.minutes(1)); + yield* Fiber.join(call.fiber); + return call.requestTimes; + }), + ); + + expect(gaps(requestTimes)).toEqual([10_000]); + }); + + describe("per-request retry options", () => { + const listProjectsWith = (retry: SupabaseApiRetryOptions) => (client: SupabaseApiClientShape) => + client.execute<"v1ListAllProjects">(operationDefinitions.v1ListAllProjects, {}, { retry }); + + const failedStatus = (exit: Exit.Exit) => { + const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; + if (!HttpClientError.isHttpClientError(error) || error.reason._tag !== "StatusCodeError") { + throw new Error("expected StatusCodeError"); + } + return error.reason.response.status; + }; + + test("maxRetries 0 makes a single attempt and returns the 503", async () => { + const { requestTimes, exit } = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjectsWith({ maxRetries: 0 }), (request) => + jsonResponse(request, 503, {}), + ); + yield* TestClock.adjust(Duration.minutes(1)); + return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) }; + }), + ); + + expect(requestTimes).toHaveLength(1); + expect(failedStatus(exit)).toBe(503); + }); + + test("wins over the client-level retry options, field by field", async () => { + const { requestTimes, exit } = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall( + listProjectsWith({ maxRetries: 1 }), + (request) => jsonResponse(request, 503, {}), + { retry: { maxRetries: 4, requestTimeoutMs: 1000 } }, + ); + yield* TestClock.adjust(Duration.minutes(1)); + return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) }; + }), + ); + + expect(requestTimes).toHaveLength(2); + expect(failedStatus(exit)).toBe(503); + }); + + test("still applies when transformClient wraps the client", async () => { + const { requestTimes } = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall( + listProjectsWith({ maxRetries: 0 }), + (request) => jsonResponse(request, 503, {}), + { + transformClient: (client) => Effect.succeed(HttpClient.mapRequest(client, (r) => r)), + }, + ); + yield* TestClock.adjust(Duration.minutes(1)); + yield* Fiber.join(call.fiber); + return { requestTimes: call.requestTimes }; + }), + ); + + expect(requestTimes).toHaveLength(1); + }); + + test("requestTimeoutMs Infinity lets an attempt outlast the default timeout", async () => { + const { attempts, exit } = await onVirtualClock( + Effect.gen(function* () { + let attempts = 0; + const fiber = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap(listProjectsWith({ requestTimeoutMs: Number.POSITIVE_INFINITY })), + Effect.exit, + Effect.provide( + httpClientLayer((request) => + Effect.suspend(() => { + attempts += 1; + return Effect.as( + Effect.sleep(Duration.minutes(5)), + jsonResponse(request, 200, []), + ); + }), + ), + ), + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.minutes(5)); + return { attempts, exit: yield* Fiber.join(fiber) }; + }), + ); + + expect(attempts).toBe(1); + expect(exit).toEqual(Exit.succeed([])); + }); + }); + + test("does not retry a POST after a 500 however long it waits", async () => { + const requestTimes = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(createProject, (request) => + jsonResponse(request, 500, {}), + ); + yield* TestClock.adjust(Duration.minutes(1)); + yield* Fiber.join(call.fiber); + return call.requestTimes; + }), + ); + + expect(requestTimes).toHaveLength(1); + }); + + test("surfaces the last 503 once a GET exhausts its retries", async () => { + const { requestTimes, exit } = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request) => + jsonResponse(request, 503, { message: "unavailable" }), + ); + yield* TestClock.adjust(Duration.minutes(1)); + return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) }; + }), + ); + + expect(requestTimes).toHaveLength(6); + const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; + expect(HttpClientError.isHttpClientError(error)).toBe(true); + if (!HttpClientError.isHttpClientError(error) || error.reason._tag !== "StatusCodeError") { + throw new Error("expected StatusCodeError"); + } + expect(error.reason.response.status).toBe(503); + }); +}); diff --git a/tools/oxlint/supabase-plugin.js b/tools/oxlint/supabase-plugin.js new file mode 100644 index 0000000000..4b564140a9 --- /dev/null +++ b/tools/oxlint/supabase-plugin.js @@ -0,0 +1,61 @@ +/** + * Oxlint plugin that keeps Management API calls on the typed `@supabase/api` client. + * + * `no-raw-management-api-url` flags string and template literals that start a `/v1/`, `/v2/` or + * `/platform/` path because such requests skip the `api.operation` span that per-command route + * detection reads. + */ + +const MESSAGE = + "Build Management API requests with the typed `@supabase/api` client (`api.v1.*`, `execute`/`executeRaw`) so the call emits the `api.operation` span; do not assemble `/v1/`, `/v2/` or `/platform/` URLs by hand."; + +// OTLP exporters use `/v1/traces`, `/v1/metrics` and `/v1/logs`, which are not Management API +// routes. +const OTLP_SIGNAL = "(?!v1/(?:traces|metrics|logs)(?:/|$))"; + +// A path that begins at its first segment with a Management API prefix, optionally behind an +// absolute origin. Service-scoped prefixes such as `/auth/v1/` and `/storage/v1/` start with a +// different segment and never match. +const LEADING_PATH = new RegExp(`^(?:https?://[^/]*)?/${OTLP_SIGNAL}(?:v[12]|platform)(?:/|$)`); + +// The same path continuing a string whose base was interpolated or concatenated before it. +const CONTINUED_PATH = new RegExp(`^/${OTLP_SIGNAL}(?:v[12]|platform)(?:/|$)`); + +function quasiText(quasi) { + return quasi.value.cooked ?? quasi.value.raw; +} + +const rule = { + meta: { + type: "problem", + docs: { + description: + "Disallow hand-built Management API URLs outside the typed `@supabase/api` client.", + }, + }, + create(context) { + const report = (node) => context.report({ node, message: MESSAGE }); + + return { + Literal(node) { + if (typeof node.value === "string" && LEADING_PATH.test(node.value)) { + report(node); + } + }, + TemplateLiteral(node) { + const matches = node.quasis.some((quasi, index) => { + const text = quasiText(quasi); + return index === 0 ? LEADING_PATH.test(text) : CONTINUED_PATH.test(text); + }); + if (matches) { + report(node); + } + }, + }; + }, +}; + +export default { + meta: { name: "supabase" }, + rules: { "no-raw-management-api-url": rule }, +}; From 82136a7fe8e5b3c0f379ca9eaeb1c1309c354fe6 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 17:40:36 +0000 Subject: [PATCH 03/26] chore(stack): bump storage to v1.83.1-r0 (#7161) This bumps storage from v1.83.0-r0 to v1.83.1-r0. https://github.com/supabase/slim-services/releases/tag/storage-v1.83.1-r0 Planned after supabase/slim-services published v1.83.1-r0. This branch is rewritten from develop whenever a newer relevant release arrives. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- apps/cli/src/shared/services/Dockerfile | 2 +- packages/stack/src/Artifacts.ts | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index e79bd0d623..b2b912b07e 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -35,7 +35,7 @@ FROM supabase/gotrue:v2.197.0 AS gotrue FROM supabase/realtime:v2.143.4 AS realtime -FROM supabase/storage-api:v1.83.0 AS storage +FROM supabase/storage-api:v1.83.1 AS storage FROM supabase/logflare:1.53.0 AS logflare # One-shot job images (not started as long-running containers); differ's alias diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index e79bd0d623..b2b912b07e 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -35,7 +35,7 @@ FROM supabase/gotrue:v2.197.0 AS gotrue FROM supabase/realtime:v2.143.4 AS realtime -FROM supabase/storage-api:v1.83.0 AS storage +FROM supabase/storage-api:v1.83.1 AS storage FROM supabase/logflare:1.53.0 AS logflare # One-shot job images (not started as long-running containers); differ's alias diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 3f66ad0cd3..a288e0812e 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -267,23 +267,23 @@ const definitions: Readonly> = { storage: definition( "storage", { - upstreamVersion: "v1.83.0", + upstreamVersion: "v1.83.1", revision: 0, image: - "ghcr.io/supabase/cli/storage:v1.83.0-r0@sha256:57846ea3e88af033b3518731e7eef8d99eea9c0d151a57e95f58fccb019304d3", - upstreamImage: "supabase/storage-api:v1.83.0", + "ghcr.io/supabase/cli/storage:v1.83.1-r0@sha256:16677fbc579cda2d16aee2582adcbdef62e6d21ad0d9bcbacaceacca0e8e6808", + upstreamImage: "supabase/storage-api:v1.83.1", natives: { "darwin-arm64": { - archive: "fc69a80e16b70fe56ea25232189c7241c9073625a6a8e0aecda57c0e06ac1dbc", - manifest: "ce54efaa1bba647b3742441ab580d6f23454a0bcec3f6dfeee9129a45c53f240", + archive: "ef3dc7153f2ab5e6e5bc8b99a0dba35a5c31881464efbb7b46ae60a17453c4c9", + manifest: "2b288c2654c6cf225b0a682ad7cbd66c004ac970cd74a201b9c5520d61ea0a7c", }, "linux-amd64": { - archive: "43a22b349e55a887eb54204c5ead98bd89396a43373f1383dadcca75d9400bc8", - manifest: "b3ef4e252455b568b325be6290d400bd9ef75868ee49e74278ccde441a05cdaa", + archive: "2805e5dbc74d42aa90ef6afca3002a9c73e47cf3dec0ff4a7faa939782f87b2e", + manifest: "36f4443178e728c74a40d958a33b9256e11bf088b31e76de758bb7547ba1b293", }, "linux-arm64": { - archive: "b8dc2e2167514f6fefff02e6a63b674ba3c7e1cb0f40438f035edbfee79bf57e", - manifest: "7925a314dd5ee2d1c037b8de60201c5c4cd75ae5ca9a5b6fc48d208a5bc4d676", + archive: "03fc2d224f2abb24ff42d6a6ccd7c5da479f79ccd1d3f5707fdf766d09bc0c10", + manifest: "703cd82ab5f5a7b74759e43b9fa316b160a779ed73904bfadfad8b6dc8979ec5", }, }, }, From b4e6611c70f2486382428aa7d627b0e8b253308b Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 20:55:02 +0000 Subject: [PATCH 04/26] chore(stack): bump analytics to v1.53.1-r0 (#7166) This bumps analytics from v1.53.0-r0 to v1.53.1-r0. https://github.com/supabase/slim-services/releases/tag/analytics-v1.53.1-r0 Planned after supabase/slim-services published v1.53.1-r0. This branch is rewritten from develop whenever a newer relevant release arrives. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- apps/cli/src/shared/services/Dockerfile | 2 +- packages/stack/src/Artifacts.ts | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index b2b912b07e..477bd2572c 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -37,7 +37,7 @@ FROM supabase/realtime:v2.143.4 AS realtime FROM supabase/storage-api:v1.83.1 AS storage -FROM supabase/logflare:1.53.0 AS logflare +FROM supabase/logflare:1.53.1 AS logflare # One-shot job images (not started as long-running containers); differ's alias # is resolved via dockerfileServiceImage("differ") in commands/db/diff/pgadmin-diff.ts. FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index b2b912b07e..477bd2572c 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -37,7 +37,7 @@ FROM supabase/realtime:v2.143.4 AS realtime FROM supabase/storage-api:v1.83.1 AS storage -FROM supabase/logflare:1.53.0 AS logflare +FROM supabase/logflare:1.53.1 AS logflare # One-shot job images (not started as long-running containers); differ's alias # is resolved via dockerfileServiceImage("differ") in commands/db/diff/pgadmin-diff.ts. FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index a288e0812e..30cf5b7684 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -418,23 +418,23 @@ const definitions: Readonly> = { analytics: definition( "analytics", { - upstreamVersion: "v1.53.0", + upstreamVersion: "v1.53.1", revision: 0, image: - "ghcr.io/supabase/cli/analytics:v1.53.0-r0@sha256:fad27128c4d39f85545ab3300c3e4a51bec5d1519c87bb75b7ab2926bba4fe90", - upstreamImage: "supabase/logflare:1.53.0", + "ghcr.io/supabase/cli/analytics:v1.53.1-r0@sha256:8052bc661373ae604c0213f14f23b6ff8543896b92f7a78f01f2079f27fae9de", + upstreamImage: "supabase/logflare:1.53.1", natives: { "darwin-arm64": { - archive: "37f7486fd2235330cbf173ef574aabb6d17cf2c2391e12febc7a1df2911a20a5", - manifest: "861578b203821d65c9ff6fde5c7a32c61630331c03f4be59afd9fe3e53237508", + archive: "ef10709e22367a6775946b6095f10898d2e357478ec4eadfdd2177b31b23871a", + manifest: "0e0821744e82e09a689d43daf78d2eeb6901ff686841c2fa215696901c955850", }, "linux-amd64": { - archive: "ded6b2687a2eec39e1ea5175cd27f8e7bacc5af97d24b68e9468667d2ea77916", - manifest: "6a244ac0302231d78eb04fdc61f8aa64103f495381ee4f1d6a5bb05a6f5892c2", + archive: "f13fa6093a1d58cf9a1672a6624f9563af728262d4a01b8a8f971effea7bd5e3", + manifest: "bb5fc95810a3bcecc6605716c8501fa3ec54c5b9ad1454bc26fdb3ad142ad1bc", }, "linux-arm64": { - archive: "e9c7846f8ca37c9321fdd1e1588cba71f15bd516797d163024a929d056992a8b", - manifest: "6b9f3bc6cc760372ddc272e373894abacfbafeb96c044caf7cd7c51a0d8025eb", + archive: "9559cef18cc1f63b8c7284285d8402187863d4fcfe2cdc96dc65407fa522179b", + manifest: "2ee730a2b333b52f1f22e7493a05d0c9f11d533a81929243aa265348d76f415e", }, }, }, From 5806448112af182a11d3dc73a573e5a9901fd2a3 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Wed, 14 Oct 2026 21:27:48 +0000 Subject: [PATCH 05/26] chore(api): sync Management API OpenAPI spec (#7163) This PR was automatically created to sync the generated `@supabase/api` package with the latest Management API OpenAPI document. Changes were detected in the upstream OpenAPI documents exposed by `https://api.supabase.com/api/v1-json` and `https://api.supabase.com/api/v2-json`. Co-authored-by: jgoux <1443499+jgoux@users.noreply.github.com> --- packages/api/src/generated/contracts.ts | 266 ++++---- packages/api/src/generated/openapi.json | 792 ++++++++++++------------ 2 files changed, 545 insertions(+), 513 deletions(-) diff --git a/packages/api/src/generated/contracts.ts b/packages/api/src/generated/contracts.ts index 74b70cb40a..6a546ee8b9 100644 --- a/packages/api/src/generated/contracts.ts +++ b/packages/api/src/generated/contracts.ts @@ -14042,10 +14042,9 @@ export const V2OrganizationsSlugWebhooksDeliveriesIdGetOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - response_headers: Schema.Union([ - Schema.Record(Schema.String, Schema.String), - Schema.Null, - ]).annotate({ description: "HTTP headers of the response, `{}` if unavailable." }), + response_headers: Schema.Record(Schema.String, Schema.String).annotate({ + description: "HTTP headers of the response, `{}` if unavailable.", + }), response_body: Schema.Union([ Schema.Union([ Schema.Record(Schema.String, Schema.String).annotate({ @@ -14058,6 +14057,8 @@ export const V2OrganizationsSlugWebhooksDeliveriesIdGetOutput = Schema.Struct({ ]), Schema.Null, ]), + error_code: Schema.Union([Schema.String, Schema.Null]), + error_message: Schema.Union([Schema.String, Schema.Null]), attempt_timestamp: Schema.String.annotate({ description: "Timestamp of the attempt.", format: "date-time", @@ -14085,27 +14086,6 @@ export const V2OrganizationsSlugWebhooksDeliveriesIdGetOutput = Schema.Struct({ "v1.organization.member.role.updated", "v1.organization.billing.plan.upgraded", "v1.organization.billing.plan.downgraded", - "project.v1.paused", - "project.v1.created", - "project.v1.restored", - "project.v1.transferred", - "project.v1.removed", - "project.v1.restarted", - "project.v1.status.changed", - "project.v1.backup.started", - "project.v1.branch.created", - "project.v1.branch.updated", - "project.v1.branch.removed", - "organization.v1.member.invitation.created", - "organization.v1.member.invitation.canceled", - "organization.v1.member.added", - "organization.v1.member.removed", - "organization.v1.member.role.assigned", - "organization.v1.member.role.removed", - "organization.v1.member.role.updated", - "organization.v1.billing.plan.upgraded", - "organization.v1.billing.plan.downgraded", - "project.v1.branch.deleted", ]).annotate({ description: "Webhook event type." }), OpenString, ]), @@ -14261,7 +14241,7 @@ export const V2OrganizationsSlugWebhooksEndpointsDeleteOutput = Schema.Struct({ Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), ), custom_headers: Schema.optionalKey( - Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + Schema.Record(Schema.String, Schema.String).annotate({ description: "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", }), @@ -14396,7 +14376,7 @@ export const V2OrganizationsSlugWebhooksEndpointsGetOutput = Schema.Struct({ Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), ), custom_headers: Schema.optionalKey( - Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + Schema.Record(Schema.String, Schema.String).annotate({ description: "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", }), @@ -14543,7 +14523,7 @@ export const V2OrganizationsSlugWebhooksEndpointsIdDeleteOutput = Schema.Struct( }) .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), custom_headers: Schema.optionalKey( - Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + Schema.Record(Schema.String, Schema.String).annotate({ description: "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", }), @@ -14687,10 +14667,9 @@ export const V2OrganizationsSlugWebhooksEndpointsIdDeliveriesGetOutput = Schema. expected: "a value less than or equal to 9007199254740991", }), ), - response_headers: Schema.Union([ - Schema.Record(Schema.String, Schema.String), - Schema.Null, - ]).annotate({ description: "HTTP headers of the response, `{}` if unavailable." }), + response_headers: Schema.Record(Schema.String, Schema.String).annotate({ + description: "HTTP headers of the response, `{}` if unavailable.", + }), response_body: Schema.Union([ Schema.Union([ Schema.Record(Schema.String, Schema.String).annotate({ @@ -14703,10 +14682,69 @@ export const V2OrganizationsSlugWebhooksEndpointsIdDeliveriesGetOutput = Schema. ]), Schema.Null, ]), + error_code: Schema.Union([Schema.String, Schema.Null]), + error_message: Schema.Union([Schema.String, Schema.Null]), attempt_timestamp: Schema.String.annotate({ description: "Timestamp of the attempt.", format: "date-time", }), + event: Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + payload: Schema.Struct({ + organization_slug: Schema.String.annotate({ description: "Organization slug" }).check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + project_ref: Schema.Union([ + Schema.String.annotate({ description: "Project ref" }) + .check( + Schema.isMinCodePoints(20).annotate({ + expected: "a string with at least 20 code points", + }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ + expected: "a string with at most 20 code points", + }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + Schema.Null, + ]), + }).annotate({ description: "Final data sent to the consumer." }), + timestamp: Schema.String.annotate({ + description: "Timestamp of event publication.", + format: "date-time", + }), + }), }), }), ), @@ -14831,7 +14869,7 @@ export const V2OrganizationsSlugWebhooksEndpointsIdGetOutput = Schema.Struct({ }) .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), custom_headers: Schema.optionalKey( - Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + Schema.Record(Schema.String, Schema.String).annotate({ description: "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", }), @@ -14936,7 +14974,7 @@ export const V2OrganizationsSlugWebhooksEndpointsIdPatchInput = Schema.Struct({ ), ), custom_headers: Schema.optionalKey( - Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + Schema.Record(Schema.String, Schema.String).annotate({ description: "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", }), @@ -15044,7 +15082,7 @@ export const V2OrganizationsSlugWebhooksEndpointsIdPatchOutput = Schema.Struct({ }) .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), custom_headers: Schema.optionalKey( - Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + Schema.Record(Schema.String, Schema.String).annotate({ description: "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", }), @@ -15112,27 +15150,6 @@ export const V2OrganizationsSlugWebhooksEndpointsIdTestPostInput = Schema.Struct "v1.organization.member.role.updated", "v1.organization.billing.plan.upgraded", "v1.organization.billing.plan.downgraded", - "project.v1.paused", - "project.v1.created", - "project.v1.restored", - "project.v1.transferred", - "project.v1.removed", - "project.v1.restarted", - "project.v1.status.changed", - "project.v1.backup.started", - "project.v1.branch.created", - "project.v1.branch.updated", - "project.v1.branch.removed", - "organization.v1.member.invitation.created", - "organization.v1.member.invitation.canceled", - "organization.v1.member.added", - "organization.v1.member.removed", - "organization.v1.member.role.assigned", - "organization.v1.member.role.removed", - "organization.v1.member.role.updated", - "organization.v1.billing.plan.upgraded", - "organization.v1.billing.plan.downgraded", - "project.v1.branch.deleted", ]).annotate({ description: "Webhook event type." }), }), }), @@ -15206,7 +15223,7 @@ export const V2OrganizationsSlugWebhooksEndpointsPostInput = Schema.Struct({ }) .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), custom_headers: Schema.optionalKey( - Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + Schema.Record(Schema.String, Schema.String).annotate({ description: "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", }), @@ -15308,7 +15325,7 @@ export const V2OrganizationsSlugWebhooksEndpointsPostOutput = Schema.Struct({ }) .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), custom_headers: Schema.optionalKey( - Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + Schema.Record(Schema.String, Schema.String).annotate({ description: "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", }), @@ -15452,10 +15469,9 @@ export const V2ProjectsRefWebhooksDeliveriesIdGetOutput = Schema.Struct({ expected: "a value less than or equal to 9007199254740991", }), ), - response_headers: Schema.Union([ - Schema.Record(Schema.String, Schema.String), - Schema.Null, - ]).annotate({ description: "HTTP headers of the response, `{}` if unavailable." }), + response_headers: Schema.Record(Schema.String, Schema.String).annotate({ + description: "HTTP headers of the response, `{}` if unavailable.", + }), response_body: Schema.Union([ Schema.Union([ Schema.Record(Schema.String, Schema.String).annotate({ @@ -15468,6 +15484,8 @@ export const V2ProjectsRefWebhooksDeliveriesIdGetOutput = Schema.Struct({ ]), Schema.Null, ]), + error_code: Schema.Union([Schema.String, Schema.Null]), + error_message: Schema.Union([Schema.String, Schema.Null]), attempt_timestamp: Schema.String.annotate({ description: "Timestamp of the attempt.", format: "date-time", @@ -15495,27 +15513,6 @@ export const V2ProjectsRefWebhooksDeliveriesIdGetOutput = Schema.Struct({ "v1.organization.member.role.updated", "v1.organization.billing.plan.upgraded", "v1.organization.billing.plan.downgraded", - "project.v1.paused", - "project.v1.created", - "project.v1.restored", - "project.v1.transferred", - "project.v1.removed", - "project.v1.restarted", - "project.v1.status.changed", - "project.v1.backup.started", - "project.v1.branch.created", - "project.v1.branch.updated", - "project.v1.branch.removed", - "organization.v1.member.invitation.created", - "organization.v1.member.invitation.canceled", - "organization.v1.member.added", - "organization.v1.member.removed", - "organization.v1.member.role.assigned", - "organization.v1.member.role.removed", - "organization.v1.member.role.updated", - "organization.v1.billing.plan.upgraded", - "organization.v1.billing.plan.downgraded", - "project.v1.branch.deleted", ]).annotate({ description: "Webhook event type." }), OpenString, ]), @@ -15683,7 +15680,7 @@ export const V2ProjectsRefWebhooksEndpointsDeleteOutput = Schema.Struct({ Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), ), custom_headers: Schema.optionalKey( - Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + Schema.Record(Schema.String, Schema.String).annotate({ description: "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", }), @@ -15824,7 +15821,7 @@ export const V2ProjectsRefWebhooksEndpointsGetOutput = Schema.Struct({ Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), ), custom_headers: Schema.optionalKey( - Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + Schema.Record(Schema.String, Schema.String).annotate({ description: "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", }), @@ -15977,7 +15974,7 @@ export const V2ProjectsRefWebhooksEndpointsIdDeleteOutput = Schema.Struct({ }) .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), custom_headers: Schema.optionalKey( - Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + Schema.Record(Schema.String, Schema.String).annotate({ description: "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", }), @@ -16127,10 +16124,9 @@ export const V2ProjectsRefWebhooksEndpointsIdDeliveriesGetOutput = Schema.Struct expected: "a value less than or equal to 9007199254740991", }), ), - response_headers: Schema.Union([ - Schema.Record(Schema.String, Schema.String), - Schema.Null, - ]).annotate({ description: "HTTP headers of the response, `{}` if unavailable." }), + response_headers: Schema.Record(Schema.String, Schema.String).annotate({ + description: "HTTP headers of the response, `{}` if unavailable.", + }), response_body: Schema.Union([ Schema.Union([ Schema.Record(Schema.String, Schema.String).annotate({ @@ -16143,10 +16139,69 @@ export const V2ProjectsRefWebhooksEndpointsIdDeliveriesGetOutput = Schema.Struct ]), Schema.Null, ]), + error_code: Schema.Union([Schema.String, Schema.Null]), + error_message: Schema.Union([Schema.String, Schema.Null]), attempt_timestamp: Schema.String.annotate({ description: "Timestamp of the attempt.", format: "date-time", }), + event: Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + payload: Schema.Struct({ + organization_slug: Schema.String.annotate({ description: "Organization slug" }).check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + project_ref: Schema.Union([ + Schema.String.annotate({ description: "Project ref" }) + .check( + Schema.isMinCodePoints(20).annotate({ + expected: "a string with at least 20 code points", + }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ + expected: "a string with at most 20 code points", + }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + Schema.Null, + ]), + }).annotate({ description: "Final data sent to the consumer." }), + timestamp: Schema.String.annotate({ + description: "Timestamp of event publication.", + format: "date-time", + }), + }), }), }), ), @@ -16277,7 +16332,7 @@ export const V2ProjectsRefWebhooksEndpointsIdGetOutput = Schema.Struct({ }) .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), custom_headers: Schema.optionalKey( - Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + Schema.Record(Schema.String, Schema.String).annotate({ description: "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", }), @@ -16388,7 +16443,7 @@ export const V2ProjectsRefWebhooksEndpointsIdPatchInput = Schema.Struct({ ), ), custom_headers: Schema.optionalKey( - Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + Schema.Record(Schema.String, Schema.String).annotate({ description: "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", }), @@ -16496,7 +16551,7 @@ export const V2ProjectsRefWebhooksEndpointsIdPatchOutput = Schema.Struct({ }) .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), custom_headers: Schema.optionalKey( - Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + Schema.Record(Schema.String, Schema.String).annotate({ description: "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", }), @@ -16570,27 +16625,6 @@ export const V2ProjectsRefWebhooksEndpointsIdTestPostInput = Schema.Struct({ "v1.organization.member.role.updated", "v1.organization.billing.plan.upgraded", "v1.organization.billing.plan.downgraded", - "project.v1.paused", - "project.v1.created", - "project.v1.restored", - "project.v1.transferred", - "project.v1.removed", - "project.v1.restarted", - "project.v1.status.changed", - "project.v1.backup.started", - "project.v1.branch.created", - "project.v1.branch.updated", - "project.v1.branch.removed", - "organization.v1.member.invitation.created", - "organization.v1.member.invitation.canceled", - "organization.v1.member.added", - "organization.v1.member.removed", - "organization.v1.member.role.assigned", - "organization.v1.member.role.removed", - "organization.v1.member.role.updated", - "organization.v1.billing.plan.upgraded", - "organization.v1.billing.plan.downgraded", - "project.v1.branch.deleted", ]).annotate({ description: "Webhook event type." }), }), }), @@ -16670,7 +16704,7 @@ export const V2ProjectsRefWebhooksEndpointsPostInput = Schema.Struct({ }) .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), custom_headers: Schema.optionalKey( - Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + Schema.Record(Schema.String, Schema.String).annotate({ description: "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", }), @@ -16772,7 +16806,7 @@ export const V2ProjectsRefWebhooksEndpointsPostOutput = Schema.Struct({ }) .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), custom_headers: Schema.optionalKey( - Schema.Union([Schema.Record(Schema.String, Schema.String), Schema.Null]).annotate({ + Schema.Record(Schema.String, Schema.String).annotate({ description: "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", }), diff --git a/packages/api/src/generated/openapi.json b/packages/api/src/generated/openapi.json index 0d81577761..9cc8aaedea 100644 --- a/packages/api/src/generated/openapi.json +++ b/packages/api/src/generated/openapi.json @@ -14722,24 +14722,17 @@ ] }, "custom_headers": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9-]+$" - }, - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ], "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", "example": { "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" } }, "created_by": { @@ -15504,24 +15497,17 @@ ] }, "custom_headers": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9-]+$" - }, - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ], "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", "example": { "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" } }, "created_by": { @@ -16200,24 +16186,17 @@ ] }, "custom_headers": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9-]+$" - }, - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ], "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", "example": { "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" } }, "signing_secret": { @@ -16356,24 +16335,17 @@ ] }, "custom_headers": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9-]+$" - }, - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ], "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", "example": { "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" } }, "created_by": { @@ -17097,24 +17069,17 @@ ] }, "custom_headers": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9-]+$" - }, - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ], "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", "example": { "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" } }, "created_by": { @@ -17930,24 +17895,17 @@ ] }, "custom_headers": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9-]+$" - }, - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ], "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", "example": { "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" } }, "created_by": { @@ -18720,24 +18678,17 @@ ] }, "custom_headers": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9-]+$" - }, - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ], "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", "example": { "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" } }, "signing_secret": { @@ -18884,24 +18835,17 @@ ] }, "custom_headers": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9-]+$" - }, - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ], "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", "example": { "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" } }, "created_by": { @@ -19703,20 +19647,13 @@ "description": "HTTP status code of the response, `0` if unavailable." }, "response_headers": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ], + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, "description": "HTTP headers of the response, `{}` if unavailable." }, "response_body": { @@ -19744,11 +19681,97 @@ } ] }, + "error_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "attempt_timestamp": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "description": "Timestamp of the attempt." + }, + "event": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded" + ], + "description": "Webhook event type." + }, + "payload": { + "type": "object", + "properties": { + "organization_slug": { + "type": "string", + "pattern": "^[\\w-]+$", + "description": "Organization slug", + "example": "tsrqponmlkjihgfedcba" + }, + "project_ref": { + "anyOf": [ + { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "description": "Project ref", + "example": "abcdefghijklmnopqrst" + }, + { + "type": "null" + } + ] + } + }, + "required": ["organization_slug", "project_ref"], + "description": "Final data sent to the consumer." + }, + "timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of event publication." + } + }, + "required": ["type", "payload", "timestamp"] } }, "required": [ @@ -19758,7 +19781,10 @@ "response_code", "response_headers", "response_body", - "attempt_timestamp" + "error_code", + "error_message", + "attempt_timestamp", + "event" ] } }, @@ -21390,7 +21416,6 @@ "type": "object", "properties": { "type": { - "description": "Webhook event type.", "type": "string", "enum": [ "v1.project.paused", @@ -21412,29 +21437,9 @@ "v1.organization.member.role.removed", "v1.organization.member.role.updated", "v1.organization.billing.plan.upgraded", - "v1.organization.billing.plan.downgraded", - "project.v1.paused", - "project.v1.created", - "project.v1.restored", - "project.v1.transferred", - "project.v1.removed", - "project.v1.restarted", - "project.v1.status.changed", - "project.v1.backup.started", - "project.v1.branch.created", - "project.v1.branch.updated", - "project.v1.branch.removed", - "organization.v1.member.invitation.created", - "organization.v1.member.invitation.canceled", - "organization.v1.member.added", - "organization.v1.member.removed", - "organization.v1.member.role.assigned", - "organization.v1.member.role.removed", - "organization.v1.member.role.updated", - "organization.v1.billing.plan.upgraded", - "organization.v1.billing.plan.downgraded", - "project.v1.branch.deleted" - ] + "v1.organization.billing.plan.downgraded" + ], + "description": "Webhook event type." } }, "required": ["type"] @@ -21528,23 +21533,16 @@ "description": "HTTP status code of the response, `0` if unavailable." }, "response_headers": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ], - "description": "HTTP headers of the response, `{}` if unavailable." - }, - "response_body": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "HTTP headers of the response, `{}` if unavailable." + }, + "response_body": { "anyOf": [ { "anyOf": [ @@ -21569,6 +21567,26 @@ } ] }, + "error_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "attempt_timestamp": { "type": "string", "format": "date-time", @@ -21579,7 +21597,6 @@ "type": "object", "properties": { "type": { - "description": "Webhook event type.", "type": "string", "enum": [ "v1.project.paused", @@ -21601,29 +21618,9 @@ "v1.organization.member.role.removed", "v1.organization.member.role.updated", "v1.organization.billing.plan.upgraded", - "v1.organization.billing.plan.downgraded", - "project.v1.paused", - "project.v1.created", - "project.v1.restored", - "project.v1.transferred", - "project.v1.removed", - "project.v1.restarted", - "project.v1.status.changed", - "project.v1.backup.started", - "project.v1.branch.created", - "project.v1.branch.updated", - "project.v1.branch.removed", - "organization.v1.member.invitation.created", - "organization.v1.member.invitation.canceled", - "organization.v1.member.added", - "organization.v1.member.removed", - "organization.v1.member.role.assigned", - "organization.v1.member.role.removed", - "organization.v1.member.role.updated", - "organization.v1.billing.plan.upgraded", - "organization.v1.billing.plan.downgraded", - "project.v1.branch.deleted" - ] + "v1.organization.billing.plan.downgraded" + ], + "description": "Webhook event type." }, "payload": { "type": "object", @@ -21670,6 +21667,8 @@ "response_code", "response_headers", "response_body", + "error_code", + "error_message", "attempt_timestamp", "event" ] @@ -23189,24 +23188,17 @@ ] }, "custom_headers": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9-]+$" - }, - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ], "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", "example": { "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" } }, "created_by": { @@ -23969,24 +23961,17 @@ ] }, "custom_headers": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9-]+$" - }, - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ], "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", "example": { "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" } }, "created_by": { @@ -24665,24 +24650,17 @@ ] }, "custom_headers": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9-]+$" - }, - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ], "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", "example": { "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" } }, "signing_secret": { @@ -24819,24 +24797,17 @@ ] }, "custom_headers": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9-]+$" - }, - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ], "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", "example": { "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" } }, "created_by": { @@ -25558,24 +25529,17 @@ ] }, "custom_headers": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9-]+$" - }, - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ], "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", "example": { "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" } }, "created_by": { @@ -26389,24 +26353,17 @@ ] }, "custom_headers": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9-]+$" - }, - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ], "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", "example": { "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" } }, "created_by": { @@ -27179,24 +27136,17 @@ ] }, "custom_headers": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9-]+$" - }, - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ], "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", "example": { "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" } }, "signing_secret": { @@ -27341,24 +27291,17 @@ ] }, "custom_headers": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string", - "pattern": "^[a-zA-Z0-9-]+$" - }, - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ], "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", "example": { "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" } }, "created_by": { @@ -28158,20 +28101,13 @@ "description": "HTTP status code of the response, `0` if unavailable." }, "response_headers": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ], + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, "description": "HTTP headers of the response, `{}` if unavailable." }, "response_body": { @@ -28199,11 +28135,97 @@ } ] }, + "error_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "attempt_timestamp": { "type": "string", "format": "date-time", "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", "description": "Timestamp of the attempt." + }, + "event": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded" + ], + "description": "Webhook event type." + }, + "payload": { + "type": "object", + "properties": { + "organization_slug": { + "type": "string", + "pattern": "^[\\w-]+$", + "description": "Organization slug", + "example": "tsrqponmlkjihgfedcba" + }, + "project_ref": { + "anyOf": [ + { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "description": "Project ref", + "example": "abcdefghijklmnopqrst" + }, + { + "type": "null" + } + ] + } + }, + "required": ["organization_slug", "project_ref"], + "description": "Final data sent to the consumer." + }, + "timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of event publication." + } + }, + "required": ["type", "payload", "timestamp"] } }, "required": [ @@ -28213,7 +28235,10 @@ "response_code", "response_headers", "response_body", - "attempt_timestamp" + "error_code", + "error_message", + "attempt_timestamp", + "event" ] } }, @@ -29843,7 +29868,6 @@ "type": "object", "properties": { "type": { - "description": "Webhook event type.", "type": "string", "enum": [ "v1.project.paused", @@ -29865,29 +29889,9 @@ "v1.organization.member.role.removed", "v1.organization.member.role.updated", "v1.organization.billing.plan.upgraded", - "v1.organization.billing.plan.downgraded", - "project.v1.paused", - "project.v1.created", - "project.v1.restored", - "project.v1.transferred", - "project.v1.removed", - "project.v1.restarted", - "project.v1.status.changed", - "project.v1.backup.started", - "project.v1.branch.created", - "project.v1.branch.updated", - "project.v1.branch.removed", - "organization.v1.member.invitation.created", - "organization.v1.member.invitation.canceled", - "organization.v1.member.added", - "organization.v1.member.removed", - "organization.v1.member.role.assigned", - "organization.v1.member.role.removed", - "organization.v1.member.role.updated", - "organization.v1.billing.plan.upgraded", - "organization.v1.billing.plan.downgraded", - "project.v1.branch.deleted" - ] + "v1.organization.billing.plan.downgraded" + ], + "description": "Webhook event type." } }, "required": ["type"] @@ -29979,20 +29983,13 @@ "description": "HTTP status code of the response, `0` if unavailable." }, "response_headers": { - "anyOf": [ - { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - } - }, - { - "type": "null" - } - ], + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, "description": "HTTP headers of the response, `{}` if unavailable." }, "response_body": { @@ -30020,6 +30017,26 @@ } ] }, + "error_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, "attempt_timestamp": { "type": "string", "format": "date-time", @@ -30030,7 +30047,6 @@ "type": "object", "properties": { "type": { - "description": "Webhook event type.", "type": "string", "enum": [ "v1.project.paused", @@ -30052,29 +30068,9 @@ "v1.organization.member.role.removed", "v1.organization.member.role.updated", "v1.organization.billing.plan.upgraded", - "v1.organization.billing.plan.downgraded", - "project.v1.paused", - "project.v1.created", - "project.v1.restored", - "project.v1.transferred", - "project.v1.removed", - "project.v1.restarted", - "project.v1.status.changed", - "project.v1.backup.started", - "project.v1.branch.created", - "project.v1.branch.updated", - "project.v1.branch.removed", - "organization.v1.member.invitation.created", - "organization.v1.member.invitation.canceled", - "organization.v1.member.added", - "organization.v1.member.removed", - "organization.v1.member.role.assigned", - "organization.v1.member.role.removed", - "organization.v1.member.role.updated", - "organization.v1.billing.plan.upgraded", - "organization.v1.billing.plan.downgraded", - "project.v1.branch.deleted" - ] + "v1.organization.billing.plan.downgraded" + ], + "description": "Webhook event type." }, "payload": { "type": "object", @@ -30121,6 +30117,8 @@ "response_code", "response_headers", "response_body", + "error_code", + "error_message", "attempt_timestamp", "event" ] From c4ccf77d44811722c8ab37f81bb289f528f29187 Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Thu, 15 Oct 2026 03:46:32 +0000 Subject: [PATCH 06/26] chore(cli): map gen types, signing-key and bearer-jwt permissions (#7149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps `gen types`, `gen signing-key` and `gen bearer-jwt` to the Management API operations they call, so a scoped access token can be granted per command. `gen keys` stays pending. `gen types` picks its target from flag values, so it declares flag conditions instead of a database target: `--local`, `--db-url` and `--postgrest-v9-compat` turn every call off. TypeScript calls the types endpoint. Other languages look the project up, fall back to the preview branch config on a 404, and reach the database through the database config resolver (login role, pooler config) and the pooler retry. The project picker and the linked-project cache fill are declared too. `signing-key` and `bearer-jwt` make no calls. Shared block change: a target-free `dbConfigPoolerFallbackLinked` for commands that choose their target from flag values (`dbConfigPoolerFallback` is built from the same entries), and the first fallback context is reworded to fit every command. Dump, pull and the db-config tests are unchanged. `gen types` runs through the real database config resolver and linked-project cache with the Management API recorded, for the TypeScript, pooler, IPv6 retry, preview branch, picker, `--local=false` and `--db-url` paths. On this command the pooler, login-role and network-ban calls are each declared by more than one source, so removing one source alone is not caught by a test; the grants are per operation either way. No operations without FGA permissions. Only `typescript` and `go` run through the recording mocks. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Andrew Valleteau --- .../src/command-internal/db-config.layer.ts | 42 +- .../gen/bearer-jwt/bearer-jwt.command.ts | 5 + .../bearer-jwt/bearer-jwt.integration.test.ts | 11 +- .../gen/signing-key/signing-key.command.ts | 2 + .../signing-key.integration.test.ts | 11 +- .../src/commands/gen/types/SIDE_EFFECTS.md | 29 +- .../src/commands/gen/types/types.command.ts | 70 ++- .../gen/types/types.integration.test.ts | 418 +++++++++++++++++- .../helpers/permission-pending-commands.ts | 3 - 9 files changed, 557 insertions(+), 34 deletions(-) diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 345f443517..0f9a50a969 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -91,7 +91,7 @@ export const dbConfigLinked = block("db-config", linkedEntries); /** * `DbConfigResolver` on the linked path; `--local` and `--db-url` make no API calls. Commands * include it with `withDbTarget`. The container retry that `db dump`, `db pull` and - * `db remote commit` run is {@link dbConfigPoolerFallback}; `gen types --project-id` fetches the + * `db remote commit` run is {@link dbConfigPoolerFallback}; `gen types` fetches the * pooler config without `--project-ref`, so that command declares its own entry. */ export const dbConfig = block( @@ -102,38 +102,54 @@ export const dbConfig = block( })), ); -/** - * `DbConfigResolver.resolvePoolerFallback`, the pooler retry after a linked dump, declarative export - * or pull diff fails with an IPv6 connectivity error against a direct host the CLI could reach. - */ -export const dbConfigPoolerFallback = block("db-config-pooler-fallback", [ +const poolerFallbackEntries: ReadonlyArray> = [ { operationId: "v1-get-pooler-config", kind: "required", - when: [{ dbTarget: ["linked"] }], context: - "a linked dump, declarative export or pull diff fails with an IPv6 connectivity error against the direct database host, and there is no usable saved supabase/.temp/pooler-url", + "the command's database work fails with an IPv6 connectivity error against the direct database host, and there is no usable saved supabase/.temp/pooler-url", }, { operationId: "v1-create-login-role", kind: "required", - when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + when: [{ flag: "password", present: false }], context: - "the same retry, and no database password supplied by the command (an empty one still mints a login role) or set in SUPABASE_DB_PASSWORD or the project .env", + "the same retry, and no database password supplied by the command (an empty one still mints a login role) or set in SUPABASE_DB_PASSWORD or the project .env (an ad-hoc project ref ignores the last two)", }, { operationId: "v1-list-all-network-bans", kind: "best-effort", - when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + when: [{ flag: "password", present: false }], context: "the same retry, after three failed attempts to connect as the temporary login role", }, { operationId: "v1-delete-network-bans", kind: "best-effort", - when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + when: [{ flag: "password", present: false }], context: "the same retry, after three failed attempts to connect as the temporary login role", }, -]); +]; + +/** + * The same calls as {@link dbConfigPoolerFallback} without its target condition, for `gen types`, + * which picks its target from flag values. + */ +export const dbConfigPoolerFallbackLinked = block( + "db-config-pooler-fallback", + poolerFallbackEntries, +); + +/** + * `DbConfigResolver.resolvePoolerFallback`, the pooler retry after a linked dump, declarative export + * or pull diff fails with an IPv6 connectivity error against a direct host the CLI could reach. + */ +export const dbConfigPoolerFallback = block( + "db-config-pooler-fallback", + poolerFallbackEntries.map((entry) => ({ + ...entry, + when: [{ dbTarget: ["linked" as const] }, ...(entry.when ?? [])], + })), +); const DIRECT_PORT = 5432; const TCP_PROBE_TIMEOUT = Duration.seconds(5); diff --git a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.command.ts b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.command.ts index 0e6063ee95..10ae0ffc41 100644 --- a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.command.ts +++ b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.command.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; @@ -57,6 +58,10 @@ const genBearerJwtRuntimeLayer = Layer.mergeAll( export const genBearerJwtCommand = Command.make("bearer-jwt", config).pipe( Command.withDescription("Generate a Bearer Auth JWT for accessing Data API"), Command.withShortDescription("Generate a Bearer Auth JWT for accessing Data API"), + withPermissions({ + operations: [], + noApiEffectFlags: ["role", "sub", "exp", "valid-for", "payload"], + }), Command.withHandler((flags) => genBearerJwt(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts index 09ea2ad12c..6b28bb7ff2 100644 --- a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts +++ b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts @@ -19,6 +19,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { GLOBAL_FLAGS } from "../../../command-internal/global-flags.ts"; import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; @@ -86,7 +87,7 @@ function setup(options: SetupOptions = {}) { mockStdin(options.stdinIsTty ?? false, options.pipedAnswer), Layer.succeed(DebugLogger, { debug: () => Effect.void, http: () => Effect.void }), ); - return { layer, out, telemetry }; + return { layer, out, telemetry, api }; } const jsonText = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -145,9 +146,15 @@ const testRoot = Command.make("supabase").pipe( describe("gen bearer-jwt integration", () => { it.effect("mints a token with the built-in default ES256 key when no config exists", () => { - const { layer, out } = setup(); + const { layer, out, api } = setup(); return Effect.gen(function* () { yield* genBearerJwt(baseFlags); + assertPermissionDrift({ + command: "gen bearer-jwt", + activeFlags: { "valid-for": true }, + requests: api.requests, + exact: true, + }); expect(out.stdoutText.endsWith("\n")).toBe(true); expect(out.stdoutText.indexOf("\n")).toBe(out.stdoutText.length - 1); diff --git a/apps/cli/src/commands/gen/signing-key/signing-key.command.ts b/apps/cli/src/commands/gen/signing-key/signing-key.command.ts index 74b1f21df5..e07a227cf7 100644 --- a/apps/cli/src/commands/gen/signing-key/signing-key.command.ts +++ b/apps/cli/src/commands/gen/signing-key/signing-key.command.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; @@ -59,6 +60,7 @@ export const genSigningKeyCommand = Command.make("signing-key", config).pipe( description: "Append a new key to the configured signing key file", }, ]), + withPermissions({ operations: [], noApiEffectFlags: ["algorithm", "append"] }), Command.withHandler((flags) => genSigningKey(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/gen/signing-key/signing-key.integration.test.ts b/apps/cli/src/commands/gen/signing-key/signing-key.integration.test.ts index 4a050fbc88..ea6e572452 100644 --- a/apps/cli/src/commands/gen/signing-key/signing-key.integration.test.ts +++ b/apps/cli/src/commands/gen/signing-key/signing-key.integration.test.ts @@ -33,6 +33,7 @@ import { withConfigEnv, withEmptyConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { DebugLogger } from "../../../command-internal/debug-logger.service.ts"; import { GLOBAL_FLAGS, YesFlag } from "../../../command-internal/global-flags.ts"; @@ -117,7 +118,7 @@ function setup(options: SetupOptions = {}) { // Listed after buildTestRuntime so it overrides the real spawner from BunServices. mockGitCheckIgnore(options.gitCheckIgnoreExitCode ?? 1), ); - return { layer, out, telemetry }; + return { layer, out, telemetry, api }; } const writeConfig = Effect.fnUntraced(function* (contents: string) { @@ -217,10 +218,16 @@ const testRoot = Command.make("supabase").pipe( describe("gen signing-key integration", () => { it.effect("prints a generated key to stdout when no signing_keys_path is configured", () => { - const { layer, out } = setup(); + const { layer, out, api } = setup(); return Effect.gen(function* () { const path = yield* Path.Path; yield* genSigningKey({ algorithm: "ES256", append: false }); + assertPermissionDrift({ + command: "gen signing-key", + activeFlags: { algorithm: true }, + requests: api.requests, + exact: true, + }); const parsed = yield* Schema.decodeEffect(storedSigningKeyJson)(out.stdoutText); expect(parsed.alg).toBe("ES256"); diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index ea0a319e33..a2aa88bf41 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -27,21 +27,24 @@ is on, `--local` resolves the project stack through `DbConfigResolver` ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | project-ref paths (`--linked`, `--project-id`, the implicit linked fallback), via `LinkedProjectCache.cache`, when the file is absent | -No files are written. +`--local` and `--db-url` write no files. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------- | ------------ | ---------------------- | ------------------------------------------ | -| `GET` | `/v1/projects/{ref}/types/typescript` | Bearer token | none | TypeScript type definitions text | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | (presence only; `404` ⇒ branch ref) | -| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | none | `db_host`, `db_port`, `db_user`, `db_pass` | -| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------------- | ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects/{ref}/types/typescript` | Bearer token | none | TypeScript type definitions text | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | non-TypeScript lookup (`404` ⇒ branch ref); also the linked-project cache fill on every project-ref path | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | none | `db_host`, `db_port`, `db_user`, `db_pass` | +| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` | +| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none | +| `GET` | `/v1/projects` | Bearer token | none | project list for the ref prompt: no ref from a flag, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, and an interactive terminal | The TypeScript endpoint is called for `--linked`, `--project-id`, and the implicit linked-project fallback when `--lang=typescript`. For other languages on those @@ -56,7 +59,9 @@ target host is the project's direct `db.` host and the pooler URL matches t expected tenant and pooler domain. An explicit `--project-id` ref fetches the primary pooler config for that ref to build the fallback connection (the saved workdir `.temp/pooler-url` is ignored because the ref may differ from the linked -workdir). `--local` and `--db-url` do not call the Management API. +workdir). The network-ban calls happen only on project refs, from the third attempt +to connect as the temporary login role, and never for preview branches. `--local` and +`--db-url` do not call the Management API. ## Subprocesses diff --git a/apps/cli/src/commands/gen/types/types.command.ts b/apps/cli/src/commands/gen/types/types.command.ts index 75ee00a62c..fdf68fd631 100644 --- a/apps/cli/src/commands/gen/types/types.command.ts +++ b/apps/cli/src/commands/gen/types/types.command.ts @@ -1,5 +1,18 @@ import { Argument, Command, Flag, Param } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withContext, + type EntryCondition, +} from "../../../command-internal/command-permissions/model.ts"; +import { + dbConfigLinked, + dbConfigPoolerFallbackLinked, +} from "../../../command-internal/db-config.layer.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { parseSchemaFlags } from "../../../command-internal/schema-flags.ts"; @@ -9,7 +22,11 @@ import { PERSISTENT_VALUE_FLAG_SHORTHANDS, } from "../../../shared/cli/cobra-flag-groups.ts"; import { genTypes } from "./types.handler.ts"; -import { GEN_TYPES_LANGUAGES, genTypesLanguageFlags } from "./types.languages.ts"; +import { + GEN_TYPES_LANGUAGES, + GEN_TYPES_LANGUAGE_FLAG_NAMES, + genTypesLanguageFlags, +} from "./types.languages.ts"; import { genTypesRuntimeLayer } from "./types.layers.ts"; const config = { @@ -88,6 +105,16 @@ const commandConfig = { export type GenTypesFlags = CliCommand.Command.Config.Infer & Readonly>; +const NON_TYPESCRIPT = GEN_TYPES_LANGUAGES.filter((lang) => lang !== "typescript"); +const nonTypescript = { flag: "lang", oneOf: NON_TYPESCRIPT } as const; +const notBranch = "the ref names a project, not a preview branch"; +// With any of these the run uses a local or `--db-url` database, or fails before calling the API. +const remoteTarget: ReadonlyArray = [ + { flag: "local", is: false }, + { flag: "db-url", present: false }, + { flag: "postgrest-v9-compat", is: false }, +]; + export const genTypesCommand = Command.make("types", commandConfig).pipe( Command.withDescription("Generate types from Postgres schema."), Command.withShortDescription("Generate types from Postgres schema"), @@ -109,6 +136,47 @@ export const genTypesCommand = Command.make("types", commandConfig).pipe( description: "Generate types from a database URL", }, ]), + withPermissions( + remoteTarget.reduce( + (fragment, condition) => withCondition(fragment, condition), + compose( + { + operations: [ + { + operationId: "v1-generate-typescript-types", + kind: "required", + when: [{ flag: "lang", oneOf: ["typescript"] }], + }, + { operationId: "v1-get-project", kind: "required", when: [nonTypescript] }, + { + operationId: "v1-get-a-branch-config", + kind: "required", + when: [nonTypescript], + context: "the project lookup returns 404, so the ref names a preview branch", + }, + { + operationId: "v1-get-pooler-config", + kind: "required", + when: [nonTypescript], + context: `${notBranch}, the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url (--project-id ignores the saved file)`, + }, + { + operationId: "v1-get-pooler-config", + kind: "required", + when: [nonTypescript], + context: + "the ref names a preview branch and generating against its direct host fails with an IPv6 connectivity error", + }, + ], + noApiEffectFlags: ["linked", "schema", "query-timeout", ...GEN_TYPES_LANGUAGE_FLAG_NAMES], + }, + withCondition(withContext(dbConfigLinked, notBranch), nonTypescript), + withCondition(withContext(dbConfigPoolerFallbackLinked, notBranch), nonTypescript), + withCondition(projectRefPicker, { flag: "project-id", present: false }), + linkedProjectCache, + ), + ), + ), Command.withHandler((flags) => genTypes(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-id"], config: flagsConfig }), diff --git a/apps/cli/src/commands/gen/types/types.integration.test.ts b/apps/cli/src/commands/gen/types/types.integration.test.ts index 5a65539593..ac78a25ef3 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -1,5 +1,6 @@ import { tmpdir } from "node:os"; import { describe, expect, it } from "@effect/vitest"; +import { vi } from "vitest"; import { BunPath, BunServices } from "@effect/platform-bun"; import type { V1CreateLoginRoleOutput, @@ -30,17 +31,34 @@ import { import { DnsResolverFlag, OutputFlag } from "../../../command-internal/global-flags.ts"; import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + primaryPoolerConfigs, + provideEnv, + realDbConfigResolver, + realLinkedProjectCache, + recordingProjectHttpClient, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../tests/helpers/permission-real-layers.ts"; +import { withReachableDirectHost } from "../../../../tests/helpers/refuse-invalid-db-hosts.ts"; import { buildTestRuntime, VALID_REF, + VALID_TOKEN, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, + mockCommandPlatformApi, mockCommandPlatformApiService, mockTelemetryStateTracked, } from "../../../../tests/helpers/command-mocks.ts"; import type { PgConnInput } from "../../../command-internal/db-connection.service.ts"; import type { DbConnectError } from "../../../command-internal/db-connection.errors.ts"; +import { DbConnection } from "../../../command-internal/db-connection.service.ts"; import { toConnectError } from "../../../command-internal/db-connection.sql-pg.layer.ts"; import type { DbConfigError } from "../../../command-internal/db-config.service.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; @@ -59,6 +77,15 @@ import { GenTypesToolFailedError, } from "./types.generator.service.ts"; +const fixtureNet = vi.hoisted(() => ({ directHostReachable: false })); + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net, fixtureNet)), +); + const path = Effect.runSync(Effect.provide(Path.Path, BunPath.layer)); const makeWorkdir = Effect.fnUntraced(function* (prefix: string) { @@ -2895,3 +2922,392 @@ describe("gen types", () => { ); }); }); + +const BRANCH_REF = "branchbranchbranchbr"; + +const projectBody = (ref: string) => ({ + id: ref, + ref, + organization_id: "org-id", + organization_slug: "org", + name: "demo", + region: "us-east-1", + created_at: "2025-01-01T00:00:00Z", + status: "ACTIVE_HEALTHY", + database: { + host: `db.${ref}.invalid`, + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}); + +/** + * Runs `gen types` through the real database config resolver and linked-project cache, with the + * Management API recorded at the HTTP level: the typed client's requests in `api.requests`, the + * resolver's in the stubbed fetch, and the cache's in `http.calls`. The handler's own settings + * use `projectHost: "invalid"`, so the pooler fallback treats `db..invalid` as the direct host. + */ +const setupRecorded = Effect.fnUntraced(function* (opts: { + readonly args: ReadonlyArray; + /** Written to `supabase/.temp/project-ref`. */ + readonly projectRefFile?: string; + /** The ref the cache's project lookup answers for. */ + readonly cacheRef?: string; + readonly branchOf?: string; + readonly pickRef?: string; + readonly generator?: ReturnType; +}) { + const workdir = yield* makeWorkdir("supabase-gen-types-recorded-"); + yield* ensureDefaultConfig(workdir); + if (opts.projectRefFile !== undefined) { + yield* writeTempFile(workdir, "project-ref", opts.projectRefFile); + } + const out = mockOutput({ + format: "text", + interactive: opts.pickRef !== undefined, + promptSelectResponses: opts.pickRef === undefined ? undefined : [opts.pickRef], + }); + const telemetry = mockTelemetryStateTracked(); + const generator = opts.generator ?? mockGenTypesGenerator({ output: "generated" }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + const { pathname } = new URL(request.url); + if (pathname === "/v1/projects") { + return jsonResponse(request, 200, [projectBody(opts.pickRef ?? VALID_REF)]); + } + const branchMatch = /^\/v1\/branches\/(?[^/]+)$/u.exec(pathname); + if (branchMatch?.groups?.["ref"] !== undefined) { + return jsonResponse(request, 200, { + ref: branchMatch.groups["ref"], + postgres_version: "15.1", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: `db.${branchMatch.groups["ref"]}.invalid`, + db_port: 5432, + db_user: "postgres", + db_pass: "branch-password", + jwt_secret: "secret", + }); + } + const poolerMatch = /^\/v1\/projects\/(?[^/]+)\/config\/database\/pooler$/u.exec( + pathname, + ); + if (poolerMatch?.groups?.["ref"] !== undefined) { + return jsonResponse(request, 200, primaryPoolerConfigs(poolerMatch.groups["ref"])); + } + const typesMatch = /^\/v1\/projects\/(?[^/]+)\/types\/typescript$/u.exec(pathname); + if (typesMatch !== null) { + return jsonResponse(request, 200, { types: "export type Database = {};" }); + } + const projectMatch = /^\/v1\/projects\/(?[^/]+)$/u.exec(pathname); + if (projectMatch?.groups?.["ref"] !== undefined) { + return projectMatch.groups["ref"] === opts.branchOf + ? jsonResponse(request, 404, { message: "Project not found" }) + : jsonResponse(request, 200, projectBody(projectMatch.groups["ref"])); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); + const http = recordingProjectHttpClient(opts.cacheRef ?? VALID_REF); + const cliSettings = mockCommandSettings({ + workdir, + projectHost: "invalid", + projectId: Option.none(), + }); + const runtime = buildTestRuntime({ + out, + api, + cliSettings, + telemetry: telemetry.layer, + tty: mockTty({ stdinIsTty: opts.pickRef !== undefined }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: http.layer, + cliSettings: mockCommandSettings({ workdir, projectId: Option.none() }), + }), + }); + const layer = Layer.mergeAll( + runtime, + BunServices.layer, + mockInspectSpawner().layer, + Stdio.layerTest({ args: Effect.succeed(opts.args) }), + Layer.succeed(OutputFlag, Option.none()), + Layer.succeed(DnsResolverFlag, "native" as const), + realDbConfigResolver({ + workdir, + dbConnection: Layer.succeed(DbConnection, { + connect: () => + Effect.succeed({ + exec: () => Effect.void, + execBatch: () => Effect.void, + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }), + }), + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }), + generator.layer, + ); + const stubbed: Array = []; + const recordedRequests = () => [ + ...api.requests.map(({ method, url }) => ({ method, url })), + ...asRecordedRequests(stubbed), + ...http.calls, + ]; + const run = (effect: Effect.Effect) => + Effect.gen(function* () { + yield* stubManagementApi(stubbed, serveLinkedDatabase(opts.cacheRef ?? VALID_REF)); + return yield* effect; + }).pipe( + Effect.scoped, + Effect.provide(layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + return { workdir, out, generator, api, http, stubbed, recordedRequests, run }; +}); + +// Effective values of the switches the declaration checks (both default to false). +const DEFAULT_SWITCHES = { local: false, "postgrest-v9-compat": false }; + +const methodPaths = (requests: ReadonlyArray<{ readonly method: string; readonly url: string }>) => + requests.map(({ method, url }) => `${method} ${new URL(url).pathname}`); + +describe("gen types permissions through the real layers", () => { + it.effect("generates typescript for --project-id with one types call and the cache fill", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const ctx = yield* setupRecorded({ + args: ["gen", "types", "--project-id", VALID_REF], + }); + yield* ctx.run(genTypes(defaultFlags({ projectId: Option.some(VALID_REF) }))); + + expect(ctx.out.stdoutText).toBe("export type Database = {};"); + expect(methodPaths(ctx.api.requests)).toEqual([ + `GET /v1/projects/${VALID_REF}/types/typescript`, + ]); + expect(ctx.stubbed).toEqual([]); + expect(methodPaths(ctx.http.calls)).toEqual([`GET /v1/projects/${VALID_REF}`]); + // The cache fill also calls the project lookup; without it, no TypeScript entry may require one. + assertPermissionDrift({ + command: "gen types", + activeFlags: { ...DEFAULT_SWITCHES, "project-id": true, lang: "typescript" }, + requests: ctx.api.requests, + exact: true, + }); + expect(yield* fs.exists(`${ctx.workdir}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "gen types", + activeFlags: { ...DEFAULT_SWITCHES, "project-id": true, lang: "typescript" }, + requests: ctx.recordedRequests(), + exact: true, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("looks the project up, then reaches the database through the pooler for go", () => + Effect.gen(function* () { + const ctx = yield* setupRecorded({ + args: ["gen", "types", "--project-id", VALID_REF, "--lang", "go"], + }); + yield* ctx.run(genTypes(defaultFlags({ projectId: Option.some(VALID_REF), lang: "go" }))); + + expect(methodPaths(ctx.api.requests)).toEqual([`GET /v1/projects/${VALID_REF}`]); + expect(ctx.stubbed.map(({ method, path }) => `${method} ${path}`)).toEqual([ + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + `POST /v1/projects/${VALID_REF}/cli/login-role`, + ]); + expect(methodPaths(ctx.http.calls)).toEqual([`GET /v1/projects/${VALID_REF}`]); + expect(ctx.generator.calls[0]?.conn.host).toBe("aws-0-us-east-1.pooler.supabase.com"); + assertPermissionDrift({ + command: "gen types", + activeFlags: { ...DEFAULT_SWITCHES, "project-id": true, lang: "go" }, + requests: ctx.recordedRequests(), + exact: true, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect( + "retries through the pooler after an IPv6 failure on the direct host for --linked", + () => + Effect.gen(function* () { + const ctx = yield* setupRecorded({ + args: ["gen", "types", "--linked", "--lang", "go"], + projectRefFile: VALID_REF, + generator: sequentialGenerator([ + () => Effect.fail(ipv6Failure()), + () => Effect.succeed("type Public struct {}"), + ]), + }); + yield* ctx.run( + Effect.gen(function* () { + yield* withReachableDirectHost(fixtureNet); + yield* genTypes(defaultFlags({ linked: true, lang: "go" })); + }), + ); + + expect(ctx.out.stdoutText).toBe("type Public struct {}"); + expect(methodPaths(ctx.api.requests)).toEqual([`GET /v1/projects/${VALID_REF}`]); + expect(ctx.stubbed.map(({ method, path }) => `${method} ${path}`)).toEqual([ + `POST /v1/projects/${VALID_REF}/cli/login-role`, + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + `POST /v1/projects/${VALID_REF}/cli/login-role`, + ]); + expect(ctx.generator.calls.map((call) => call.conn.host)).toEqual([ + `db.${VALID_REF}.invalid`, + "aws-0-us-east-1.pooler.supabase.com", + ]); + assertPermissionDrift({ + command: "gen types", + activeFlags: { ...DEFAULT_SWITCHES, linked: true, lang: "go" }, + requests: ctx.recordedRequests(), + exact: true, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("falls back to the preview branch config when the project lookup returns 404", () => + Effect.gen(function* () { + const ctx = yield* setupRecorded({ + args: ["gen", "types", "--project-id", BRANCH_REF, "--lang", "go"], + branchOf: BRANCH_REF, + cacheRef: BRANCH_REF, + generator: sequentialGenerator([ + () => Effect.fail(ipv6Failure()), + () => Effect.succeed("type Public struct {}"), + ]), + }); + yield* ctx.run(genTypes(defaultFlags({ projectId: Option.some(BRANCH_REF), lang: "go" }))); + + expect(methodPaths(ctx.api.requests)).toEqual([ + `GET /v1/projects/${BRANCH_REF}`, + `GET /v1/branches/${BRANCH_REF}`, + `GET /v1/projects/${BRANCH_REF}/config/database/pooler`, + ]); + expect(ctx.stubbed).toEqual([]); + expect(ctx.generator.calls.map((call) => call.conn.host)).toEqual([ + `db.${BRANCH_REF}.invalid`, + "aws-0-us-east-1.pooler.supabase.com", + ]); + expect(ctx.generator.calls[1]?.conn.password).toBe("branch-password"); + expect(methodPaths(ctx.http.calls)).toEqual([`GET /v1/projects/${BRANCH_REF}`]); + assertPermissionDrift({ + command: "gen types", + activeFlags: { ...DEFAULT_SWITCHES, "project-id": true, lang: "go" }, + requests: ctx.recordedRequests(), + exact: true, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("asks which project to use for --linked when no ref is saved", () => + Effect.gen(function* () { + const ctx = yield* setupRecorded({ + args: ["gen", "types", "--linked"], + pickRef: VALID_REF, + }); + yield* ctx.run(genTypes(defaultFlags({ linked: true }))); + + expect(methodPaths(ctx.api.requests)).toEqual([ + "GET /v1/projects", + `GET /v1/projects/${VALID_REF}/types/typescript`, + ]); + expect(methodPaths(ctx.http.calls)).toEqual([`GET /v1/projects/${VALID_REF}`]); + assertPermissionDrift({ + command: "gen types", + activeFlags: { ...DEFAULT_SWITCHES, linked: true, lang: "typescript" }, + requests: ctx.recordedRequests(), + exact: true, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect( + "the implicit linked fallback generates from the saved ref when no target flag is given", + () => + Effect.gen(function* () { + const ctx = yield* setupRecorded({ + args: ["gen", "types"], + projectRefFile: VALID_REF, + }); + yield* ctx.run(genTypes(defaultFlags())); + + expect(methodPaths(ctx.api.requests)).toEqual([ + `GET /v1/projects/${VALID_REF}/types/typescript`, + ]); + assertPermissionDrift({ + command: "gen types", + activeFlags: { ...DEFAULT_SWITCHES, lang: "typescript" }, + requests: ctx.recordedRequests(), + exact: true, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + const NO_API_SCENARIOS = [ + { + name: "--local", + args: ["gen", "types", "--local"], + flags: defaultFlags({ local: true }), + activeFlags: { ...DEFAULT_SWITCHES, local: true, lang: "typescript" }, + generates: true, + }, + { + name: "--db-url, whatever the language", + args: [ + "gen", + "types", + "--db-url", + "postgresql://postgres:postgres@127.0.0.1:5432/postgres", + "--lang", + "go", + ], + flags: defaultFlags({ + dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + lang: "go", + }), + activeFlags: { ...DEFAULT_SWITCHES, "db-url": true, lang: "go" }, + generates: true, + }, + { + name: "--postgrest-v9-compat without --db-url, which fails first", + args: ["gen", "types", "--project-id", VALID_REF, "--postgrest-v9-compat"], + flags: defaultFlags({ projectId: Option.some(VALID_REF), postgrestV9Compat: true }), + activeFlags: { + ...DEFAULT_SWITCHES, + "postgrest-v9-compat": true, + "project-id": true, + lang: "typescript", + }, + generates: false, + }, + ] as const; + + for (const scenario of NO_API_SCENARIOS) { + it.effect(`makes no Management API request for ${scenario.name}`, () => + Effect.gen(function* () { + const ctx = yield* setupRecorded({ args: scenario.args }); + const exit = yield* ctx.run(genTypes(scenario.flags)).pipe(Effect.exit); + + expect(Exit.isSuccess(exit)).toBe(scenario.generates); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain( + "--postgrest-v9-compat must used together with --db-url", + ); + } + expect(ctx.generator.calls).toHaveLength(scenario.generates ? 1 : 0); + expect(ctx.recordedRequests()).toEqual([]); + assertPermissionDrift({ + command: "gen types", + activeFlags: scenario.activeFlags, + requests: ctx.recordedRequests(), + exact: true, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + } +}); diff --git a/apps/cli/tests/helpers/permission-pending-commands.ts b/apps/cli/tests/helpers/permission-pending-commands.ts index ce0f720ea9..1f68150661 100644 --- a/apps/cli/tests/helpers/permission-pending-commands.ts +++ b/apps/cli/tests/helpers/permission-pending-commands.ts @@ -55,10 +55,7 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "functions new", "functions serve", - "gen bearer-jwt", "gen keys", - "gen signing-key", - "gen types", "init", From 8d4a2eecaba91bdd5aa9038d16762fdda3e4361b Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Thu, 15 Oct 2026 07:37:56 +0000 Subject: [PATCH 07/26] chore(cli): map branches and backups command permissions (#7152) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps `branches create|delete|disable|get|list|pause|unpause|update` and `backups list|restore` to the Management API operations they can call, so scoped PATs can be granted per command. Drift tests keep each declaration in step with what the command does. **Shared changes.** - New `branchPrompt` block (`v1-list-all-branches`, required) for the branch listing shown when no branch is given on a TTY. - The context text of `projectRefPicker`, `linkedProjectCache` and `branchRef` is reworded so it fits every command that includes it. No condition or label changed. **Blocks used.** `projectRefPicker` and `linkedProjectCache` (all ten), `branchPrompt` and `branchRef` (delete, pause, unpause, update; `get` uses `branchPrompt` and declares its name lookup itself), `upgradeSuggestion` (create, update). **Conditions.** `branches get` names the global output flags: the branch's API keys and pooler config are read for `-o` other than `pretty`, and with no `-o` when the output format is json or stream-json (`--output-format`, `--agent yes`, or a detected coding agent). This is the first declaration to name global flags in `when`. **Findings.** - `pause` and `unpause` call the project-level pause and restore operations, so they need `project_admin_write`, not a branching permission. - `disable` needs only `branching_production_delete`. - A 4xx on `create` or `update` triggers the upgrade-suggestion lookups (best-effort). **Tests.** Each command's integration test runs the project picker, the branch picker where it applies, and the real linked-project cache, and checks the exact request sequence. `branches get` covers each output mode that changes its calls; the agent-detected case is simulated through the output format. **Not covered.** Context entries (`branchPrompt`, `branchRef`, the cache, the agent-detected entry) are asserted by request sequence only. `--persistent=false` is not run on every command. **Docs.** The `branches` `SIDE_EFFECTS.md` files now give the cache path as `/supabase/.temp/linked-project.json`, and `get`'s doc states when the API keys and pooler config are read. All ten docs now list the project picker, branch picker and linked-project cache routes, and say when the cache file is written. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 Co-authored-by: Andrew Valleteau --- .../command-internal/branch-ref.resolver.ts | 5 +- .../src/commands/backups/list/SIDE_EFFECTS.md | 8 +- .../src/commands/backups/list/list.command.ts | 11 + .../backups/list/list.integration.test.ts | 83 +++++++- .../commands/backups/restore/SIDE_EFFECTS.md | 16 +- .../backups/restore/restore.command.ts | 14 ++ .../restore/restore.integration.test.ts | 141 ++++++++----- .../src/commands/branches/branches.prompt.ts | 10 + .../commands/branches/create/SIDE_EFFECTS.md | 20 +- .../branches/create/create.command.ts | 16 ++ .../create/create.integration.test.ts | 84 +++++++- .../commands/branches/delete/SIDE_EFFECTS.md | 21 +- .../branches/delete/delete.command.ts | 15 ++ .../delete/delete.integration.test.ts | 126 +++++++++++- .../commands/branches/disable/SIDE_EFFECTS.md | 16 +- .../branches/disable/disable.command.ts | 11 + .../disable/disable.integration.test.ts | 87 +++++++- .../src/commands/branches/get/SIDE_EFFECTS.md | 23 ++- .../src/commands/branches/get/get.command.ts | 63 ++++++ .../branches/get/get.integration.test.ts | 193 +++++++++++++++++- .../commands/branches/list/SIDE_EFFECTS.md | 16 +- .../commands/branches/list/list.command.ts | 11 + .../branches/list/list.integration.test.ts | 114 ++++++++++- .../commands/branches/pause/SIDE_EFFECTS.md | 21 +- .../commands/branches/pause/pause.command.ts | 15 ++ .../branches/pause/pause.integration.test.ts | 131 +++++++++++- .../commands/branches/unpause/SIDE_EFFECTS.md | 21 +- .../branches/unpause/unpause.command.ts | 15 ++ .../unpause/unpause.integration.test.ts | 153 +++++++++++++- .../commands/branches/update/SIDE_EFFECTS.md | 25 ++- .../branches/update/update.command.ts | 20 ++ .../update/update.integration.test.ts | 134 +++++++++++- apps/cli/src/config/project-ref.layer.ts | 2 +- .../telemetry/linked-project-cache.layer.ts | 3 +- .../helpers/permission-pending-commands.ts | 12 -- 35 files changed, 1476 insertions(+), 180 deletions(-) diff --git a/apps/cli/src/command-internal/branch-ref.resolver.ts b/apps/cli/src/command-internal/branch-ref.resolver.ts index fe95984d8c..062f9538d6 100644 --- a/apps/cli/src/command-internal/branch-ref.resolver.ts +++ b/apps/cli/src/command-internal/branch-ref.resolver.ts @@ -11,12 +11,13 @@ export const branchRef = block("branch-ref", [ { operationId: "v1-get-a-branch-config", kind: "required", - context: "the branch argument is a UUID", + context: "the branch given (argument or typed answer) is a UUID", }, { operationId: "v1-get-a-branch", kind: "required", - context: "the branch argument is a branch name", + context: + "the branch given (argument or typed answer) is a name, not a UUID or a 20-letter project ref", }, ]); diff --git a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md index 1609312f06..905f8cfe77 100644 --- a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md @@ -12,9 +12,10 @@ ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after the command runs (in `Effect.ensuring`), unless the file already exists, `.temp/project-ref` names a different project, no access token resolves, or the project lookup is not 200 | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes @@ -22,6 +23,7 @@ | ------ | ------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects/{ref}/database/backups` | Bearer token | none | `{region, walg_enabled, pitr_enabled, backups: [{inserted_at, status, is_physical_backup}], physical_backup_data: {earliest_physical_backup_date_unix?, latest_physical_backup_date_unix?}}` | | `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, organization_slug, region, ...}]` — TTY-prompt fallback only | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | ## Environment Variables diff --git a/apps/cli/src/commands/backups/list/list.command.ts b/apps/cli/src/commands/backups/list/list.command.ts index 069efa6a50..942b76f10c 100644 --- a/apps/cli/src/commands/backups/list/list.command.ts +++ b/apps/cli/src/commands/backups/list/list.command.ts @@ -1,6 +1,10 @@ import type * as CliCommand from "effect/cli/Command"; import { Command, Flag } from "effect/cli"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -28,6 +32,13 @@ export const backupsListCommand = Command.make("list", config).pipe( description: "List backups for a specific project", }, ]), + withPermissions( + compose( + { operations: [{ operationId: "v1-list-all-backups", kind: "required" }] }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => backupsList(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/backups/list/list.integration.test.ts b/apps/cli/src/commands/backups/list/list.integration.test.ts index 1578ca6ec6..7b844fa782 100644 --- a/apps/cli/src/commands/backups/list/list.integration.test.ts +++ b/apps/cli/src/commands/backups/list/list.integration.test.ts @@ -1,18 +1,40 @@ -import { type V1ListAllBackupsOutput } from "@supabase/api/effect"; +import { type V1ListAllBackupsOutput, type V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Path } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Path, Schema } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockCommandPlatformApi, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { backupsList } from "./list.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + const PITR_RESPONSE: typeof V1ListAllBackupsOutput.Type = { region: "ap-southeast-1", walg_enabled: true, @@ -214,6 +236,7 @@ WalgEnabled = true yield* backupsList({ projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/database/backups`); + assertPermissionDrift({ command: "backups list", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -223,9 +246,63 @@ WalgEnabled = true return Effect.gen(function* () { yield* backupsList({ projectRef: Option.some(flagRef) }); expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); + assertPermissionDrift({ + command: "backups list", + activeFlags: { "project-ref": true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); + it.effect( + "lists projects for the picker, then the backups, then fills the linked-project cache", + () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [PROJECT])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, PROJECT)); + } + return Effect.succeed(jsonResponse(request, 200, PITR_RESPONSE)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* backupsList({ projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/database/backups`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain("Southeast Asia (Singapore)"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "backups list", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("reads supabase/.temp/project-ref when env and flag are unset", () => { const fileRef = "filerefabcdefghijklm"; const out = mockOutput({ format: "text" }); diff --git a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md index 6fe56e7b23..82ed519c38 100644 --- a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md @@ -12,16 +12,18 @@ ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after the command runs (in `Effect.ensuring`), unless the file already exists, `.temp/project-ref` names a different project, no access token resolves, or the project lookup is not 200 | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------------------------------------------- | ------------ | ------------------------------------ | ------------------------------------------------------------------------------ | -| `POST` | `/v1/projects/{ref}/database/backups/restore-pitr` | Bearer token | `{recovery_time_target_unix: int64}` | none (201 Created) | -| `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, organization_slug, region, ...}]` — TTY-prompt fallback only | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------------------------- | ------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | +| `POST` | `/v1/projects/{ref}/database/backups/restore-pitr` | Bearer token | `{recovery_time_target_unix: int64}` | none (201 Created) | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, organization_slug, region, ...}]` — TTY-prompt fallback only | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | ## Environment Variables diff --git a/apps/cli/src/commands/backups/restore/restore.command.ts b/apps/cli/src/commands/backups/restore/restore.command.ts index d15aed8db9..5a51d25c68 100644 --- a/apps/cli/src/commands/backups/restore/restore.command.ts +++ b/apps/cli/src/commands/backups/restore/restore.command.ts @@ -1,6 +1,10 @@ import type * as CliCommand from "effect/cli/Command"; import { Command, Flag } from "effect/cli"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -29,6 +33,16 @@ export const backupsRestoreCommand = Command.make("restore", config).pipe( description: "Restore to the given Unix epoch timestamp", }, ]), + withPermissions( + compose( + { + operations: [{ operationId: "v1-restore-pitr-backup", kind: "required" }], + noApiEffectFlags: ["timestamp"], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => backupsRestore(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/backups/restore/restore.integration.test.ts b/apps/cli/src/commands/backups/restore/restore.integration.test.ts index fea44e6837..b6e9590eb5 100644 --- a/apps/cli/src/commands/backups/restore/restore.integration.test.ts +++ b/apps/cli/src/commands/backups/restore/restore.integration.test.ts @@ -1,6 +1,6 @@ +import type { V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; -import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { @@ -11,8 +11,29 @@ import { mockCommandPlatformApi, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { backupsRestore } from "./restore.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org_123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-01-01T00:00:00Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.example.com", + version: "15.0", + postgres_engine: "supabase-postgres", + release_channel: "ga", + }, +}; + interface SetupOpts { format?: "text" | "json" | "stream-json"; goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; @@ -49,6 +70,7 @@ describe("backups restore integration", () => { }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.body).toEqual({ recovery_time_target_unix: 0 }); + assertPermissionDrift({ command: "backups restore", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -60,6 +82,12 @@ describe("backups restore integration", () => { timestamp: Option.some(1_707_407_047), }); expect(api.requests[0]?.body).toEqual({ recovery_time_target_unix: 1_707_407_047 }); + assertPermissionDrift({ + command: "backups restore", + activeFlags: { timestamp: true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -137,6 +165,12 @@ describe("backups restore integration", () => { timestamp: Option.none(), }); expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); + assertPermissionDrift({ + command: "backups restore", + activeFlags: { "project-ref": true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -192,64 +226,61 @@ describe("backups restore integration", () => { }); }); - it.effect("prompts via TTY when no ref source matches and stdin is a TTY", () => { - const out = mockOutput({ - format: "text", - promptSelectResponses: [VALID_REF], - }); - // Branches on path: the resolver's list call gets a project array, the restore call gets 201. - const api = mockCommandPlatformApi({ - handler: (request) => { - if (request.url.includes("/v1/projects") && !request.url.includes("/database/backups")) { + it.effect( + "prompts via TTY, restores, and fills the linked-project cache through the real layer", + () => { + const out = mockOutput({ + format: "text", + promptSelectResponses: [VALID_REF], + }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.method === "POST") { + return Effect.succeed(jsonResponse(request, 201, null)); + } return Effect.succeed( - HttpClientResponse.fromWeb( + jsonResponse( request, - new Response( - JSON.stringify([ - { - id: VALID_REF, - ref: VALID_REF, - organization_id: "org_123", - organization_slug: "acme", - name: "alpha", - region: "us-east-1", - created_at: "2026-01-01T00:00:00Z", - status: "ACTIVE_HEALTHY", - database: { - host: "db.example.com", - version: "15.0", - postgres_engine: "supabase-postgres", - release_channel: "ga", - }, - }, - ]), - { status: 200, headers: { "content-type": "application/json" } }, - ), + 200, + request.url === "https://api.supabase.com/v1/projects" ? [PROJECT] : PROJECT, ), ); - } - return Effect.succeed(jsonResponse(request, 201, null)); - }, - }); - const cliSettings = mockCommandSettings({ - workdir: tempRoot.current, - projectId: Option.none(), - }); - const layer = buildTestRuntime({ - out, - api, - cliSettings, - tty: mockTty({ stdinIsTty: true, stdoutIsTty: true }), - }); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true, stdoutIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); - return Effect.gen(function* () { - yield* backupsRestore({ projectRef: Option.none(), timestamp: Option.none() }).pipe( - Effect.provide(layer), - ); - expect(out.promptSelectCalls).toHaveLength(1); - expect(out.stderrText).toContain(`Started PITR restore: ${VALID_REF}\n`); - }); - }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* backupsRestore({ projectRef: Option.none(), timestamp: Option.none() }); + expect(out.promptSelectCalls).toHaveLength(1); + expect(out.stderrText).toContain(`Started PITR restore: ${VALID_REF}\n`); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `POST https://api.supabase.com/v1/projects/${VALID_REF}/database/backups/restore-pitr`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "backups restore", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); it.effect("accepts --timestamp short alias -t in the same way (no separate parse path)", () => { const { layer, api } = setup(); diff --git a/apps/cli/src/commands/branches/branches.prompt.ts b/apps/cli/src/commands/branches/branches.prompt.ts index 9f6f6951e0..48e842d756 100644 --- a/apps/cli/src/commands/branches/branches.prompt.ts +++ b/apps/cli/src/commands/branches/branches.prompt.ts @@ -3,6 +3,7 @@ import { styleText } from "node:util"; import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts"; +import { block } from "../../command-internal/command-permissions/model.ts"; import { mapHttpError } from "../../command-internal/http-errors.ts"; import { Output } from "../../shared/output/output.service.ts"; import { detectGitBranch } from "../../shared/git/git-branch.ts"; @@ -14,6 +15,15 @@ import { BranchesListUnexpectedStatusError, } from "./branches.errors.ts"; +/** `promptBranchId`'s branch listing, shown when no branch is given on a TTY. */ +export const branchPrompt = block("branch-prompt", [ + { + operationId: "v1-list-all-branches", + kind: "required", + context: "the branch argument is omitted or empty and stdin is a TTY", + }, +]); + const mapListError = mapHttpError({ networkError: BranchesListNetworkError, statusError: BranchesListUnexpectedStatusError, diff --git a/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md index 77d169004c..a0fefccae3 100644 --- a/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md @@ -13,18 +13,20 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after the command runs (in `Effect.ensuring`), unless the file already exists, `.temp/project-ref` names a different project, no access token resolves, or the project lookup is not 200 | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -| `POST` | `/v1/projects/{ref}/branches` | Bearer token | `{branch_name, is_default: false, git_branch?, region?, desired_instance_size?, persistent?, with_data?, notify_url?}` | full `BranchResponse` | -| `GET` | `/v1/projects/{ref}` (on 4xx gated) | Bearer token | none | `{organization_slug}` | -| `GET` | `/v1/organizations/{slug}/entitlements` (gated) | Bearer token | none | `[{feature: {key}, hasAccess, ...}]` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST` | `/v1/projects/{ref}/branches` | Bearer token | `{branch_name, is_default: false, git_branch?, region?, desired_instance_size?, persistent?, with_data?, notify_url?}` | full `BranchResponse` | +| `GET` | `/v1/projects/{ref}` (on 4xx gated) | Bearer token | none | `{organization_slug}` | +| `GET` | `/v1/organizations/{slug}/entitlements` (gated) | Bearer token | none | `[{feature: {key}, hasAccess, ...}]` | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region, …}]` — no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | ## Environment Variables diff --git a/apps/cli/src/commands/branches/create/create.command.ts b/apps/cli/src/commands/branches/create/create.command.ts index 20cd15af79..4af369ac66 100644 --- a/apps/cli/src/commands/branches/create/create.command.ts +++ b/apps/cli/src/commands/branches/create/create.command.ts @@ -2,6 +2,11 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { Layer } from "effect"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; @@ -92,6 +97,17 @@ export type BranchesCreateFlags = CliCommand.Command.Config.Infer export const branchesCreateCommand = Command.make("create", config).pipe( Command.withDescription("Create a preview branch for the linked project."), Command.withShortDescription("Create a preview branch"), + withPermissions( + compose( + { + operations: [{ operationId: "v1-create-a-branch", kind: "required" }], + noApiEffectFlags: ["region", "size", "persistent", "with-data", "notify-url", "git-branch"], + }, + projectRefPicker, + upgradeSuggestion, + linkedProjectCache, + ), + ), Command.withHandler((flags) => branchesCreate(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"], config }), diff --git a/apps/cli/src/commands/branches/create/create.integration.test.ts b/apps/cli/src/commands/branches/create/create.integration.test.ts index 334e880deb..3b7a4643d7 100644 --- a/apps/cli/src/commands/branches/create/create.integration.test.ts +++ b/apps/cli/src/commands/branches/create/create.integration.test.ts @@ -1,6 +1,6 @@ import type { V1CreateABranchOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option, Schema } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema } from "effect"; import { CliError, CliOutput, Command } from "effect/cli"; import { @@ -22,6 +22,8 @@ import { useTempWorkdir, withConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { branchesCreateCommand, type BranchesCreateFlags } from "./create.command.ts"; import { branchesCreate } from "./create.handler.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; @@ -29,6 +31,8 @@ import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts type CreatedBranch = typeof V1CreateABranchOutput.Type; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const CREATED: CreatedBranch = { id: "11111111-2222-4333-8444-555555555555", name: "feat-x", @@ -183,9 +187,67 @@ describe("branches create integration", () => { branch_name: "feat-x", is_default: false, }); + assertPermissionDrift({ command: "branches create", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); + it.effect( + "lists projects for the picker, creates the branch, then fills the linked-project cache", + () => { + const out = mockOutput({ format: "text", promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.method === "POST") { + return Effect.succeed(jsonResponse(request, 201, CREATED)); + } + return Effect.succeed( + jsonResponse( + request, + 200, + request.url === "https://api.supabase.com/v1/projects" + ? [projectResponse()] + : projectResponse(), + ), + ); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }), + Layer.succeed(YesFlag, false), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* branchesCreate({ ...baseFlags, name: Option.some("feat-x") }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `POST https://api.supabase.com/v1/projects/${VALID_REF}/branches`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain("Created preview branch:"); + expect(out.stdoutText).toContain("feat-x"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "branches create", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("includes optional flags in the request body only when set", () => { const { layer, api } = setup(); return Effect.gen(function* () { @@ -205,6 +267,17 @@ describe("branches create integration", () => { with_data: true, notify_url: "https://hook.example.com", }); + assertPermissionDrift({ + command: "branches create", + activeFlags: { + region: true, + persistent: true, + "with-data": true, + "notify-url": true, + }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -366,9 +439,15 @@ describe("branches create integration", () => { }); it.effect("fires cli_upgrade_suggested with feature_key=branching_limit on 402 gated", () => { - const { layer, analytics } = setup({ status: 402, gated: true }); + const { layer, analytics, api } = setup({ status: 402, gated: true }); return Effect.gen(function* () { yield* Effect.exit(branchesCreate({ ...baseFlags, name: Option.some("feat-x") })); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `POST https://api.supabase.com/v1/projects/${VALID_REF}/branches`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + `GET https://api.supabase.com/v1/organizations/${ORG_SLUG}/entitlements`, + ]); + assertPermissionDrift({ command: "branches create", requests: api.requests }); expect(analytics.captured).toEqual([ { event: "cli_upgrade_suggested", @@ -397,6 +476,7 @@ describe("branches create integration", () => { expect(Exit.isFailure(exit)).toBe(true); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain("/branches"); + assertPermissionDrift({ command: "branches create", requests: api.requests, exact: true }); expect(out.stderrText).toContain("/org/env-org/billing"); expect(analytics.captured).toEqual([ { diff --git a/apps/cli/src/commands/branches/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/delete/SIDE_EFFECTS.md index 5c774d9adc..03e57396da 100644 --- a/apps/cli/src/commands/branches/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/delete/SIDE_EFFECTS.md @@ -6,18 +6,21 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after the command runs (in `Effect.ensuring`), unless the file already exists, `.temp/project-ref` names a different project, no access token resolves, or the project lookup is not 200 | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | When | Response | -| -------- | ------------------------------------ | ------------ | -------------------------------------------------------------- | ------------------- | -| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | `{project_ref}` | -| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | `{ref}` | -| `DELETE` | `/v1/branches/{branch_id_or_ref}` | Bearer token | always — `force` query param omitted | `{ message: "ok" }` | +| Method | Path | Auth | When | Response | +| -------- | ------------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | `{project_ref}` | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | `{ref}` | +| `DELETE` | `/v1/branches/{branch_id_or_ref}` | Bearer token | always — `force` query param omitted | `{ message: "ok" }` | +| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | `[{name, project_ref, …}]` | +| `GET` | `/v1/projects` | Bearer token | no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | `[{id, name, organization_slug, region, …}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | `{ref, name, organization_id, organization_slug}` | ## Environment Variables diff --git a/apps/cli/src/commands/branches/delete/delete.command.ts b/apps/cli/src/commands/branches/delete/delete.command.ts index d2a8d7a42c..5e1e075de0 100644 --- a/apps/cli/src/commands/branches/delete/delete.command.ts +++ b/apps/cli/src/commands/branches/delete/delete.command.ts @@ -1,6 +1,12 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { branchPrompt } from "../branches.prompt.ts"; +import { branchRef } from "../../../command-internal/branch-ref.resolver.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -21,6 +27,15 @@ export type BranchesDeleteFlags = CliCommand.Command.Config.Infer export const branchesDeleteCommand = Command.make("delete", config).pipe( Command.withDescription("Delete a preview branch by its name or ID."), Command.withShortDescription("Delete a preview branch"), + withPermissions( + compose( + { operations: [{ operationId: "v1-delete-a-branch", kind: "required" }] }, + projectRefPicker, + branchPrompt, + branchRef, + linkedProjectCache, + ), + ), Command.withHandler((flags) => branchesDelete(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), diff --git a/apps/cli/src/commands/branches/delete/delete.integration.test.ts b/apps/cli/src/commands/branches/delete/delete.integration.test.ts index 49ba2f4eb2..b7f3c424f1 100644 --- a/apps/cli/src/commands/branches/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/branches/delete/delete.integration.test.ts @@ -1,8 +1,10 @@ +import { type V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Path } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Path, Schema } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { + VALID_REF, buildTestRuntime, jsonResponse, mockCommandSettings, @@ -11,6 +13,12 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { + assertBlockDrift, + assertPermissionDrift, +} from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; +import { branchPrompt } from "../branches.prompt.ts"; import type { BranchesDeleteFlags } from "./delete.command.ts"; import { branchesDelete } from "./delete.handler.ts"; @@ -30,8 +38,47 @@ const BRANCH_CONFIG = { db_port: 5432, }; +const BRANCH_REF = "cccccccccccccccccccc"; +const OTHER_BRANCH_REF = "dddddddddddddddddddd"; +const API = "https://api.supabase.com"; + +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + +const branchListing = (projectRef: string, name: string) => ({ + id: "33333333-3333-4333-8333-333333333333", + name, + project_ref: projectRef, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + with_data: false, +}); + const tempRoot = useTempWorkdir("supabase-branches-delete-int-"); +const sequence = (requests: ReadonlyArray<{ method: string; url: string }>) => + requests.map(({ method, url }) => `${method} ${url}`); + interface SetupOpts { readonly deleteStatus?: number; } @@ -90,6 +137,11 @@ describe("branches delete integration", () => { expect(api.requests.find((r) => r.method === "DELETE")?.url).toContain( `/v1/branches/${BRANCH_UUID}`, ); + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/branches/${BRANCH_UUID}`, + `DELETE ${API}/v1/branches/${BRANCH_UUID}`, + ]); + assertPermissionDrift({ command: "branches delete", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -194,6 +246,76 @@ describe("branches delete integration", () => { expect(lookup?.url).toContain(`/v1/projects/${PARENT_REF}/branches/my-feature`); const del = api.requests.find((r) => r.method === "DELETE"); expect(del?.url).toContain(`/v1/branches/${RESOLVED_BRANCH_UUID}`); + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/projects/${PARENT_REF}/branches/my-feature`, + `DELETE ${API}/v1/branches/${RESOLVED_BRANCH_UUID}`, + ]); + assertPermissionDrift({ command: "branches delete", requests: api.requests }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "deletes the branch chosen from the pickers and fills the real linked-project cache", + () => { + const out = mockOutput({ format: "text", promptSelectResponses: [VALID_REF, BRANCH_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "GET" && request.url === `${API}/v1/projects`) { + return jsonResponse(request, 200, [PROJECT]); + } + if (request.method === "GET" && request.url === `${API}/v1/projects/${VALID_REF}`) { + return jsonResponse(request, 200, PROJECT); + } + if ( + request.method === "GET" && + request.url === `${API}/v1/projects/${VALID_REF}/branches` + ) { + return jsonResponse(request, 200, [ + branchListing(OTHER_BRANCH_REF, "other"), + branchListing(BRANCH_REF, "feat-x"), + ]); + } + if (request.method === "DELETE" && request.url === `${API}/v1/branches/${BRANCH_REF}`) { + return jsonResponse(request, 200, { message: "ok" }); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* branchesDelete(baseFlags); + + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/projects`, + `GET ${API}/v1/projects/${VALID_REF}/branches`, + `DELETE ${API}/v1/branches/${BRANCH_REF}`, + `GET ${API}/v1/projects/${VALID_REF}`, + ]); + expect(out.stderrText).toContain(`Deleted preview branch: ${BRANCH_REF}`); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "branches delete", requests: api.requests, exact: true }); + assertBlockDrift(branchPrompt, { requests: api.requests.slice(1, 2) }); }).pipe(Effect.provide(layer)); }, ); diff --git a/apps/cli/src/commands/branches/disable/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/disable/SIDE_EFFECTS.md index fb97e3e988..23430f6735 100644 --- a/apps/cli/src/commands/branches/disable/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/disable/SIDE_EFFECTS.md @@ -8,16 +8,18 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after the command runs (in `Effect.ensuring`), unless the file already exists, `.temp/project-ref` names a different project, no access token resolves, or the project lookup is not 200 | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | Request body | Response | -| -------- | ----------------------------- | ------------ | ------------ | ----------------- | -| `DELETE` | `/v1/projects/{ref}/branches` | Bearer token | none | none (expect 200) | +| Method | Path | Auth | Request body | Response | +| -------- | ----------------------------- | ------------ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DELETE` | `/v1/projects/{ref}/branches` | Bearer token | none | none (expect 200) | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region, …}]` — no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | ## Environment Variables diff --git a/apps/cli/src/commands/branches/disable/disable.command.ts b/apps/cli/src/commands/branches/disable/disable.command.ts index ed13186d2f..421b834820 100644 --- a/apps/cli/src/commands/branches/disable/disable.command.ts +++ b/apps/cli/src/commands/branches/disable/disable.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -18,6 +22,13 @@ export type BranchesDisableFlags = CliCommand.Command.Config.Infer branchesDisable(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), diff --git a/apps/cli/src/commands/branches/disable/disable.integration.test.ts b/apps/cli/src/commands/branches/disable/disable.integration.test.ts index 1ca9da0e76..4f8231ea12 100644 --- a/apps/cli/src/commands/branches/disable/disable.integration.test.ts +++ b/apps/cli/src/commands/branches/disable/disable.integration.test.ts @@ -1,19 +1,42 @@ +import type { V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import type { BranchesDisableFlags } from "./disable.command.ts"; import { branchesDisable } from "./disable.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + const tempRoot = useTempWorkdir("supabase-branches-disable-int-"); interface SetupOpts { @@ -61,9 +84,69 @@ describe("branches disable integration", () => { expect(api.requests.find((r) => r.method === "DELETE")?.url).toContain( `/v1/projects/${VALID_REF}/branches`, ); + assertPermissionDrift({ + command: "branches disable", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); + it.effect( + "lists projects for the picker, disables branching, then fills the linked-project cache", + () => { + const out = mockOutput({ format: "text", promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.method === "DELETE") { + return Effect.succeed(jsonResponse(request, 200, null)); + } + return Effect.succeed( + jsonResponse( + request, + 200, + request.url === "https://api.supabase.com/v1/projects" ? [PROJECT] : PROJECT, + ), + ); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* branchesDisable(baseFlags); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/branches`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain(`Disabled preview branching for project: ${VALID_REF}`); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "branches disable", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("fails with BranchesDisableUnexpectedStatusError on non-200", () => { const { layer } = setup({ status: 500 }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/branches/get/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/get/SIDE_EFFECTS.md index a730752166..338e1b984d 100644 --- a/apps/cli/src/commands/branches/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/get/SIDE_EFFECTS.md @@ -6,19 +6,22 @@ Same auth fallback chain (env / keyring / `~/.supabase/access-token`) as every M ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after the command runs (in `Effect.ensuring`), unless the file already exists, `.temp/project-ref` names a different project, no access token resolves, or the project lookup is not 200 | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | When | Response (used fields) | -| ------ | ------------------------------------------- | ------------ | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | input is neither a UUID nor a `^[a-z]{20}$` ref pattern (named-branch lookup) | `{project_ref, ...}` | -| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | always — branch detail / config | `{ref, db_host, db_port, db_user?, db_pass?, jwt_secret?, postgres_version, status, …}` | -| `GET` | `/v1/projects/{ref}/api-keys` | Bearer token | only when `--output` is not `pretty` | `[{name, api_key?}]` | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | only when `--output` is not `pretty` | `[SupavisorConfigResponse]` — handler filters for `database_type === "PRIMARY"` | +| Method | Path | Auth | When | Response (used fields) | +| ------ | ------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | input is neither a UUID nor a `^[a-z]{20}$` ref pattern (named-branch lookup) | `{project_ref, ...}` | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | always — branch detail / config | `{ref, db_host, db_port, db_user?, db_pass?, jwt_secret?, postgres_version, status, …}` | +| `GET` | `/v1/projects/{ref}/api-keys` | Bearer token | with `-o` other than `pretty`, or with no `-o` when the output format is json or stream-json (`--output-format`, `--agent yes`, or a detected coding agent) | `[{name, api_key?}]` | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | with `-o` other than `pretty`, or with no `-o` when the output format is json or stream-json (`--output-format`, `--agent yes`, or a detected coding agent) | `[SupavisorConfigResponse]` — handler filters for `database_type === "PRIMARY"` | +| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | `[{name, project_ref, …}]` | +| `GET` | `/v1/projects` | Bearer token | no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | `[{id, name, organization_slug, region, …}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | `{ref, name, organization_id, organization_slug}` | ## Environment Variables diff --git a/apps/cli/src/commands/branches/get/get.command.ts b/apps/cli/src/commands/branches/get/get.command.ts index 5d6b0a34e7..ea78c0721f 100644 --- a/apps/cli/src/commands/branches/get/get.command.ts +++ b/apps/cli/src/commands/branches/get/get.command.ts @@ -1,11 +1,48 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + type EntryCondition, +} from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { branchPrompt } from "../branches.prompt.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesGet } from "./get.handler.ts"; +// `get.handler.ts` skips the api-keys and pooler calls for `-o pretty`, and with no `-o` when the output format is text. +const MACHINE_OUTPUT: ReadonlyArray<{ + readonly when: ReadonlyArray; + readonly context?: string; +}> = [ + { when: [{ flag: "output", oneOf: ["env", "json", "toml", "yaml", "table", "csv"] }] }, + { + when: [ + { flag: "output", present: false }, + { flag: "output-format", oneOf: ["json", "stream-json"] }, + ], + }, + { + when: [ + { flag: "output", present: false }, + { flag: "output-format", present: false }, + { flag: "agent", oneOf: ["yes"] }, + ], + }, + { + when: [ + { flag: "output", present: false }, + { flag: "output-format", present: false }, + { flag: "agent", oneOf: ["auto"] }, + ], + context: "a coding agent is detected in the environment", + }, +]; + const config = { name: Argument.String("name").pipe( Argument.withDescription("Branch name or ID."), @@ -24,6 +61,32 @@ export const branchesGetCommand = Command.make("get", config).pipe( "Retrieve details of the specified preview branch.\n\nNote: For the main branch, password-dependent fields (POSTGRES_URL, POSTGRES_URL_NON_POOLING) are not populated because production database credentials are not retrievable via API.", ), Command.withShortDescription("Retrieve details of a preview branch"), + withPermissions( + compose( + { + operations: [ + { + operationId: "v1-get-a-branch", + kind: "required", + context: + "the branch given (argument or typed answer) is a name, not a UUID or a 20-letter project ref", + }, + { operationId: "v1-get-a-branch-config", kind: "required" }, + ...MACHINE_OUTPUT.flatMap(({ when, context }) => + ["v1-get-project-api-keys", "v1-get-pooler-config"].map((operationId) => ({ + operationId, + kind: "required" as const, + when, + ...(context === undefined ? {} : { context }), + })), + ), + ], + }, + projectRefPicker, + branchPrompt, + linkedProjectCache, + ), + ), Command.withHandler((flags) => branchesGet(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), diff --git a/apps/cli/src/commands/branches/get/get.integration.test.ts b/apps/cli/src/commands/branches/get/get.integration.test.ts index 83da3b48a4..874205d392 100644 --- a/apps/cli/src/commands/branches/get/get.integration.test.ts +++ b/apps/cli/src/commands/branches/get/get.integration.test.ts @@ -3,11 +3,13 @@ import type { V1GetABranchOutput, V1GetPoolerConfigOutput, V1GetProjectApiKeysOutput, + V1ListAllBranchesOutput, + V1ListAllProjectsOutput, } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, @@ -16,6 +18,8 @@ import { mockCommandPlatformApi, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import type { BranchesGetFlags } from "./get.command.ts"; import { branchesGet } from "./get.handler.ts"; @@ -24,6 +28,8 @@ type FindResponse = typeof V1GetABranchOutput.Type; type Pooler = typeof V1GetPoolerConfigOutput.Type; type ApiKeys = typeof V1GetProjectApiKeysOutput.Type; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + // A UUID keeps `branch_id_or_ref`'s oneOf union unambiguous; a 20-lowercase ref could match // either variant. const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; @@ -89,11 +95,46 @@ const KEYS: ApiKeys = [ { name: "service_role", api_key: "sr-key" }, ]; +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + +const LISTED_BRANCH: (typeof V1ListAllBranchesOutput.Type)[number] = { + id: BRANCH_UUID, + name: "feat-x", + project_ref: BRANCH_REF, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + with_data: false, +}; + +const API = "https://api.supabase.com"; +const DETAIL_REQUEST = `GET ${API}/v1/branches/${BRANCH_UUID}`; +const API_KEYS_REQUEST = `GET ${API}/v1/projects/${BRANCH_REF}/api-keys`; +const POOLER_REQUEST = `GET ${API}/v1/projects/${BRANCH_REF}/config/database/pooler`; + const tempRoot = useTempWorkdir("supabase-branches-get-int-"); interface SetupOpts { readonly format?: "text" | "json"; - readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; + readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml" | "table"; readonly findStatus?: number; readonly detailStatus?: number; readonly detailBody?: BranchDetail; @@ -142,6 +183,10 @@ function buildApi(opts: SetupOpts) { }); } +function requested(api: { readonly requests: ReadonlyArray<{ method: string; url: string }> }) { + return api.requests.map(({ method, url }) => `${method} ${url}`); +} + function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); const api = buildApi(opts); @@ -161,6 +206,52 @@ const baseFlags: BranchesGetFlags = { }; describe("branches get integration", () => { + it.effect("picks the project and branch on a TTY and fills the linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF, BRANCH_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.url === `${API}/v1/projects` + ? jsonResponse(request, 200, [PROJECT]) + : request.url === `${API}/v1/projects/${VALID_REF}/branches` + ? jsonResponse(request, 200, [LISTED_BRANCH]) + : request.url === `${API}/v1/branches/${BRANCH_REF}` + ? jsonResponse(request, 200, DETAIL) + : request.url === `${API}/v1/projects/${VALID_REF}` + ? jsonResponse(request, 200, PROJECT) + : jsonResponse(request, 404, { message: "unrouted" }), + ), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* branchesGet(baseFlags); + expect(requested(api)).toEqual([ + `GET ${API}/v1/projects`, + `GET ${API}/v1/projects/${VALID_REF}/branches`, + `GET ${API}/v1/branches/${BRANCH_REF}`, + `GET ${API}/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain("db.cccccccccccccccccccc.supabase.co"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "branches get", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + it.effect("fetches branch detail directly when input is a UUID (no lookup)", () => { const { layer, api } = setup(); return Effect.gen(function* () { @@ -182,23 +273,48 @@ describe("branches get integration", () => { api.requests.some((r) => r.url.includes(`/v1/projects/${VALID_REF}/branches/feat-x`)), ).toBe(true); expect(api.requests.some((r) => r.url.includes(`/v1/branches/${BRANCH_UUID}`))).toBe(true); + assertPermissionDrift({ command: "branches get", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); it.effect("renders pretty 7-col table with masked ****** for missing credentials", () => { - const { layer, out } = setup({ detailBody: DETAIL_MASKED }); + const { layer, out, api } = setup({ detailBody: DETAIL_MASKED }); return Effect.gen(function* () { yield* branchesGet({ ...baseFlags, name: Option.some(BRANCH_UUID) }); expect(out.stdoutText).toContain("HOST"); expect(out.stdoutText).toContain("******"); expect(out.stdoutText).toContain("ACTIVE_HEALTHY"); + expect(requested(api)).toEqual([DETAIL_REQUEST]); + assertPermissionDrift({ command: "branches get", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("skips api keys and pooler config for --output pretty", () => { + const { layer, out, api } = setup({ goOutput: "pretty" }); + return Effect.gen(function* () { + yield* branchesGet({ ...baseFlags, name: Option.some(BRANCH_UUID) }); + expect(out.stdoutText).toContain("HOST"); + expect(requested(api)).toEqual([DETAIL_REQUEST]); + assertPermissionDrift({ + command: "branches get", + activeFlags: { output: "pretty" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); it.effect("emits standard-env map for --output-format=json", () => { - const { layer, out } = setup({ format: "json" }); + const { layer, out, api } = setup({ format: "json" }); return Effect.gen(function* () { yield* branchesGet({ ...baseFlags, name: Option.some(BRANCH_UUID) }); + expect(requested(api)).toEqual([DETAIL_REQUEST, API_KEYS_REQUEST, POOLER_REQUEST]); + assertPermissionDrift({ + command: "branches get", + activeFlags: { "output-format": "json" }, + requests: api.requests, + exact: true, + }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); expect(success?.data).toHaveProperty("POSTGRES_URL"); @@ -237,9 +353,15 @@ describe("branches get integration", () => { it.effect( "keeps env-map keys verbatim for --output toml (map payload, exempt from CLI-1975)", () => { - const { layer, out } = setup({ goOutput: "toml" }); + const { layer, out, api } = setup({ goOutput: "toml" }); return Effect.gen(function* () { yield* branchesGet({ ...baseFlags, name: Option.some(BRANCH_UUID) }); + assertPermissionDrift({ + command: "branches get", + activeFlags: { output: "toml" }, + requests: api.requests, + exact: true, + }); // Map payloads are exempt from the struct-field PascalCase key remap; keys stay verbatim. expect(out.stdoutText).toContain('SUPABASE_URL = "'); expect(out.stdoutText).toContain('POSTGRES_URL = "'); @@ -248,14 +370,62 @@ describe("branches get integration", () => { ); it.effect("emits standard-env map for --output env (env-format encoder)", () => { - const { layer, out } = setup({ goOutput: "env" }); + const { layer, out, api } = setup({ goOutput: "env" }); return Effect.gen(function* () { yield* branchesGet({ ...baseFlags, name: Option.some(BRANCH_UUID) }); expect(out.stdoutText).toContain("SUPABASE_URL="); expect(out.stdoutText).toContain("SUPABASE_ANON_KEY="); + expect(requested(api)).toEqual([DETAIL_REQUEST, API_KEYS_REQUEST, POOLER_REQUEST]); + assertPermissionDrift({ + command: "branches get", + activeFlags: { output: "env" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); + it.effect("fetches api keys and pooler config and prints JSON for -o table", () => { + const { layer, out, api } = setup({ goOutput: "table" }); + return Effect.gen(function* () { + yield* branchesGet({ ...baseFlags, name: Option.some(BRANCH_UUID) }); + expect(requested(api)).toEqual([DETAIL_REQUEST, API_KEYS_REQUEST, POOLER_REQUEST]); + expect(yield* decodeJson(out.stdoutText)).toMatchObject({ + SUPABASE_ANON_KEY: "anon-key", + SUPABASE_SERVICE_ROLE_KEY: "sr-key", + }); + assertPermissionDrift({ + command: "branches get", + activeFlags: { output: "table" }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + for (const agent of ["auto", "yes"] as const) { + it.effect( + `fetches api keys and pooler config when --agent ${agent} yields json output (format simulated)`, + () => { + const { layer, out, api } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* branchesGet({ ...baseFlags, name: Option.some(BRANCH_UUID) }); + expect(requested(api)).toEqual([DETAIL_REQUEST, API_KEYS_REQUEST, POOLER_REQUEST]); + expect(out.messages.find((m) => m.type === "success")?.data).toHaveProperty( + "SUPABASE_ANON_KEY", + "anon-key", + ); + assertPermissionDrift({ + command: "branches get", + activeFlags: { agent }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }, + ); + } + it.effect("writes WARNING to stderr when pooler parse fails in text mode", () => { const broken: Pooler = [ { @@ -315,7 +485,7 @@ describe("branches get integration", () => { }); it.effect("fails with BranchesApiKeysUnexpectedStatusError on api-keys 403", () => { - const { layer } = setup({ goOutput: "json", apiKeysStatus: 403 }); + const { layer, api } = setup({ goOutput: "json", apiKeysStatus: 403 }); return Effect.gen(function* () { const exit = yield* Effect.exit( branchesGet({ ...baseFlags, name: Option.some(BRANCH_UUID) }), @@ -324,7 +494,14 @@ describe("branches get integration", () => { if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); expect(causeText).toContain("BranchesApiKeysUnexpectedStatusError"); + expect(causeText).toContain("unexpected get api keys status 403"); } + expect(requested(api)).toEqual([DETAIL_REQUEST, API_KEYS_REQUEST]); + assertPermissionDrift({ + command: "branches get", + activeFlags: { output: "json" }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md index 5dbdf6c1c1..a5827db494 100644 --- a/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md @@ -29,16 +29,18 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after the command runs (in `Effect.ensuring`), unless the file already exists, `.temp/project-ref` names a different project, no access token resolves, or the project lookup is not 200 | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects/{ref}/branches` | Bearer token | none | `[{id, name, project_ref, parent_project_ref, is_default, git_branch?, persistent, status, created_at, updated_at, with_data, ...}]` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------- | ------------ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/branches` | Bearer token | none | `[{id, name, project_ref, parent_project_ref, is_default, git_branch?, persistent, status, created_at, updated_at, with_data, ...}]` | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region, …}]` — no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | ## Environment Variables diff --git a/apps/cli/src/commands/branches/list/list.command.ts b/apps/cli/src/commands/branches/list/list.command.ts index 2b15000ace..e87f189c78 100644 --- a/apps/cli/src/commands/branches/list/list.command.ts +++ b/apps/cli/src/commands/branches/list/list.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -18,6 +22,13 @@ export type BranchesListFlags = CliCommand.Command.Config.Infer; export const branchesListCommand = Command.make("list", config).pipe( Command.withDescription("List all preview branches of the linked project."), Command.withShortDescription("List all preview branches"), + withPermissions( + compose( + { operations: [{ operationId: "v1-list-all-branches", kind: "required" }] }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => branchesList(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), diff --git a/apps/cli/src/commands/branches/list/list.integration.test.ts b/apps/cli/src/commands/branches/list/list.integration.test.ts index 32c36cdd44..4d98a12514 100644 --- a/apps/cli/src/commands/branches/list/list.integration.test.ts +++ b/apps/cli/src/commands/branches/list/list.integration.test.ts @@ -1,21 +1,43 @@ -import type { V1ListAllBranchesOutput } from "@supabase/api/effect"; +import type { V1ListAllBranchesOutput, V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Path } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Path, Schema } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { branchesList } from "./list.handler.ts"; type Branches = typeof V1ListAllBranchesOutput.Type; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + const SAMPLE_BRANCH: Branches[number] = { id: "11111111-2222-4333-8444-555555555555", name: "feat-1", @@ -304,9 +326,58 @@ describe("branches list integration", () => { yield* branchesList({ projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/branches`); + assertPermissionDrift({ command: "branches list", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); + it.effect( + "lists projects for the picker, then the branches, then fills the linked-project cache", + () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [PROJECT])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, PROJECT)); + } + return Effect.succeed(jsonResponse(request, 200, [SAMPLE_BRANCH])); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* branchesList({ projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain("feat-1"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "branches list", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); + describe("parent-scoped resolution after linking a branch (CLI-2167 follow-up)", () => { it.effect( "resolves the linked PARENT (not the branch's own ref) when linked to a branch, and renders the table", @@ -323,6 +394,7 @@ describe("branches list integration", () => { expect(api.requests[0]?.url).toContain(`/v1/projects/${PARENT_REF}/branches`); expect(out.stdoutText).toContain("STATUS"); expect(out.stdoutText).toContain("feat-1"); + assertPermissionDrift({ command: "branches list", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }, ); @@ -339,6 +411,42 @@ describe("branches list integration", () => { yield* writeLinkedProjectCacheFile(workdir, PARENT_REF); yield* branchesList({ projectRef: Option.some(EXPLICIT_REF) }); expect(api.requests[0]?.url).toContain(`/v1/projects/${EXPLICIT_REF}/branches`); + assertPermissionDrift({ + command: "branches list", + activeFlags: { "project-ref": true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "an empty --project-ref resolves the linked parent from the project-ref file without the picker", + () => { + const api = mockCommandPlatformApi({ + response: { status: 200, body: [SAMPLE_BRANCH] }, + }); + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const layer = buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current, projectId: Option.none() }), + tty: mockTty({ stdinIsTty: true }), + }); + return Effect.gen(function* () { + yield* writeProjectRefFile(tempRoot.current, FILE_ONLY_REF); + yield* branchesList({ projectRef: Option.some("") }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${FILE_ONLY_REF}/branches`, + ]); + expect(out.promptSelectCalls).toHaveLength(0); + assertPermissionDrift({ + command: "branches list", + activeFlags: { "project-ref": "" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }, ); diff --git a/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md index 63ca38e249..8db8776c98 100644 --- a/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md @@ -6,18 +6,21 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after the command runs (in `Effect.ensuring`), unless the file already exists, `.temp/project-ref` names a different project, no access token resolves, or the project lookup is not 200 | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | When | Response | -| ------ | ------------------------------------ | ------------ | -------------------------------------------------------------- | ----------------- | -| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | `{project_ref}` | -| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | `{ref}` | -| `POST` | `/v1/projects/{branch_ref}/pause` | Bearer token | always — final pause action | none (expect 200) | +| Method | Path | Auth | When | Response | +| ------ | ------------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | `{project_ref}` | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | `{ref}` | +| `POST` | `/v1/projects/{branch_ref}/pause` | Bearer token | always — final pause action | none (expect 200) | +| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | `[{name, project_ref, …}]` | +| `GET` | `/v1/projects` | Bearer token | no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | `[{id, name, organization_slug, region, …}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | `{ref, name, organization_id, organization_slug}` | ## Environment Variables diff --git a/apps/cli/src/commands/branches/pause/pause.command.ts b/apps/cli/src/commands/branches/pause/pause.command.ts index 5361025bbe..dc9645edbd 100644 --- a/apps/cli/src/commands/branches/pause/pause.command.ts +++ b/apps/cli/src/commands/branches/pause/pause.command.ts @@ -1,6 +1,12 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { branchPrompt } from "../branches.prompt.ts"; +import { branchRef } from "../../../command-internal/branch-ref.resolver.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -22,6 +28,15 @@ export type BranchesPauseFlags = CliCommand.Command.Config.Infer; export const branchesPauseCommand = Command.make("pause", config).pipe( Command.withDescription("Pause a preview branch."), Command.withShortDescription("Pause a preview branch"), + withPermissions( + compose( + { operations: [{ operationId: "v1-pause-a-project", kind: "required" }] }, + projectRefPicker, + branchPrompt, + branchRef, + linkedProjectCache, + ), + ), Command.withHandler((flags) => branchesPause(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), diff --git a/apps/cli/src/commands/branches/pause/pause.integration.test.ts b/apps/cli/src/commands/branches/pause/pause.integration.test.ts index 5aee8fc099..ef89da7392 100644 --- a/apps/cli/src/commands/branches/pause/pause.integration.test.ts +++ b/apps/cli/src/commands/branches/pause/pause.integration.test.ts @@ -1,7 +1,8 @@ +import { type V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, @@ -12,6 +13,8 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import type { BranchesPauseFlags } from "./pause.command.ts"; import { branchesPause } from "./pause.handler.ts"; @@ -42,8 +45,60 @@ const BRANCH_NAMED_LOOKUP = { with_data: false, }; +const OTHER_BRANCH_REF = "dddddddddddddddddddd"; +const API = "https://api.supabase.com"; + +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + const tempRoot = useTempWorkdir("supabase-branches-pause-int-"); +const sequence = (requests: ReadonlyArray<{ method: string; url: string }>) => + requests.map(({ method, url }) => `${method} ${url}`); + +function pickerApi() { + return mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "GET" && request.url === `${API}/v1/projects`) { + return jsonResponse(request, 200, [PROJECT]); + } + if (request.method === "GET" && request.url === `${API}/v1/projects/${VALID_REF}`) { + return jsonResponse(request, 200, PROJECT); + } + if ( + request.method === "GET" && + request.url === `${API}/v1/projects/${VALID_REF}/branches` + ) { + return jsonResponse(request, 200, [ + { ...BRANCH_NAMED_LOOKUP, name: "other", project_ref: OTHER_BRANCH_REF }, + BRANCH_NAMED_LOOKUP, + ]); + } + if (request.method === "POST" && request.url === `${API}/v1/projects/${BRANCH_REF}/pause`) { + return jsonResponse(request, 200, null); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); +} + interface SetupOpts { readonly pauseStatus?: number; } @@ -107,6 +162,7 @@ describe("branches pause integration", () => { const post = api.requests.find((r) => r.method === "POST"); expect(post?.url).toContain(`/v1/projects/${BRANCH_REF}/pause`); expect(api.requests.find((r) => r.method === "GET")).toBeUndefined(); + assertPermissionDrift({ command: "branches pause", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -120,6 +176,11 @@ describe("branches pause integration", () => { expect(api.requests.find((r) => r.method === "POST")?.url).toContain( `/v1/projects/${BRANCH_REF}/pause`, ); + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/branches/${BRANCH_UUID}`, + `POST ${API}/v1/projects/${BRANCH_REF}/pause`, + ]); + assertPermissionDrift({ command: "branches pause", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -133,6 +194,11 @@ describe("branches pause integration", () => { expect(api.requests.find((r) => r.method === "POST")?.url).toContain( `/v1/projects/${BRANCH_REF}/pause`, ); + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/projects/${VALID_REF}/branches/feat-x`, + `POST ${API}/v1/projects/${BRANCH_REF}/pause`, + ]); + assertPermissionDrift({ command: "branches pause", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -168,4 +234,65 @@ describe("branches pause integration", () => { expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); + + it.effect( + "pauses the branch chosen from the pickers and fills the real linked-project cache", + () => { + const out = mockOutput({ format: "text", promptSelectResponses: [VALID_REF, BRANCH_REF] }); + const api = pickerApi(); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* branchesPause(baseFlags); + + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/projects`, + `GET ${API}/v1/projects/${VALID_REF}/branches`, + `POST ${API}/v1/projects/${BRANCH_REF}/pause`, + `GET ${API}/v1/projects/${VALID_REF}`, + ]); + expect(out.stderrText).toContain(`Selected branch ID: ${BRANCH_REF}`); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "branches pause", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("prompts with the branch listing when the branch argument is an empty string", () => { + const out = mockOutput({ format: "text", promptSelectResponses: [BRANCH_REF] }); + const api = pickerApi(); + const layer = buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + tty: mockTty({ stdinIsTty: true }), + }); + return Effect.gen(function* () { + yield* branchesPause({ ...baseFlags, name: Option.some("") }); + + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/projects/${VALID_REF}/branches`, + `POST ${API}/v1/projects/${BRANCH_REF}/pause`, + ]); + assertPermissionDrift({ command: "branches pause", requests: api.requests }); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md index d931d0763b..bf48961321 100644 --- a/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md @@ -6,18 +6,21 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after the command runs (in `Effect.ensuring`), unless the file already exists, `.temp/project-ref` names a different project, no access token resolves, or the project lookup is not 200 | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | When | Response | -| ------ | ------------------------------------ | ------------ | -------------------------------------------------------------- | ----------------- | -| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | `{project_ref}` | -| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | `{ref}` | -| `POST` | `/v1/projects/{branch_ref}/restore` | Bearer token | always — final unpause action | none (expect 200) | +| Method | Path | Auth | When | Response | +| ------ | ------------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | `{project_ref}` | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | `{ref}` | +| `POST` | `/v1/projects/{branch_ref}/restore` | Bearer token | always — final unpause action | none (expect 200) | +| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | `[{name, project_ref, …}]` | +| `GET` | `/v1/projects` | Bearer token | no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | `[{id, name, organization_slug, region, …}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | `{ref, name, organization_id, organization_slug}` | ## Environment Variables diff --git a/apps/cli/src/commands/branches/unpause/unpause.command.ts b/apps/cli/src/commands/branches/unpause/unpause.command.ts index 66ede0fb27..fec9be4e19 100644 --- a/apps/cli/src/commands/branches/unpause/unpause.command.ts +++ b/apps/cli/src/commands/branches/unpause/unpause.command.ts @@ -1,6 +1,12 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { branchPrompt } from "../branches.prompt.ts"; +import { branchRef } from "../../../command-internal/branch-ref.resolver.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -22,6 +28,15 @@ export type BranchesUnpauseFlags = CliCommand.Command.Config.Infer branchesUnpause(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), diff --git a/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts b/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts index 79b69717a2..46b20bace2 100644 --- a/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts +++ b/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts @@ -1,22 +1,65 @@ +import { type V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { + VALID_REF, buildTestRuntime, jsonResponse, mockCommandSettings, mockCommandPlatformApi, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import type { BranchesUnpauseFlags } from "./unpause.command.ts"; import { branchesUnpause } from "./unpause.handler.ts"; // 20-lowercase ref (matches V1RestoreAProjectInput.ref plain pattern). const BRANCH_REF = "cccccccccccccccccccc"; +const OTHER_BRANCH_REF = "dddddddddddddddddddd"; +const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; +const API = "https://api.supabase.com"; + +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + +const branchListing = (projectRef: string, name: string) => ({ + id: "11111111-1111-4111-8111-111111111111", + name, + project_ref: projectRef, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: false, +}); + const tempRoot = useTempWorkdir("supabase-branches-unpause-int-"); +const sequence = (requests: ReadonlyArray<{ method: string; url: string }>) => + requests.map(({ method, url }) => `${method} ${url}`); + interface SetupOpts { readonly restoreStatus?: number; } @@ -51,6 +94,41 @@ describe("branches unpause integration", () => { expect(api.requests.find((r) => r.method === "POST")?.url).toContain( `/v1/projects/${BRANCH_REF}/restore`, ); + expect(sequence(api.requests)).toEqual([`POST ${API}/v1/projects/${BRANCH_REF}/restore`]); + assertPermissionDrift({ command: "branches unpause", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("resolves a UUID through the branch config before unpausing", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => + request.method === "GET" && request.url === `${API}/v1/branches/${BRANCH_UUID}` + ? jsonResponse(request, 200, { + ref: BRANCH_REF, + postgres_version: "15", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: `db.${BRANCH_REF}.supabase.co`, + db_port: 5432, + }) + : jsonResponse(request, 200, null), + ), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + }); + return Effect.gen(function* () { + yield* branchesUnpause({ ...baseFlags, name: Option.some(BRANCH_UUID) }); + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/branches/${BRANCH_UUID}`, + `POST ${API}/v1/projects/${BRANCH_REF}/restore`, + ]); + assertPermissionDrift({ command: "branches unpause", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -77,4 +155,75 @@ describe("branches unpause integration", () => { } }).pipe(Effect.provide(layer)); }); + + it.effect( + "unpauses the branch chosen from the pickers and fills the real linked-project cache", + () => { + const out = mockOutput({ format: "text", promptSelectResponses: [VALID_REF, BRANCH_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "GET" && request.url === `${API}/v1/projects`) { + return jsonResponse(request, 200, [PROJECT]); + } + if (request.method === "GET" && request.url === `${API}/v1/projects/${VALID_REF}`) { + return jsonResponse(request, 200, PROJECT); + } + if ( + request.method === "GET" && + request.url === `${API}/v1/projects/${VALID_REF}/branches` + ) { + return jsonResponse(request, 200, [ + branchListing(OTHER_BRANCH_REF, "other"), + branchListing(BRANCH_REF, "feat-x"), + ]); + } + if ( + request.method === "POST" && + request.url === `${API}/v1/projects/${BRANCH_REF}/restore` + ) { + return jsonResponse(request, 200, null); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* branchesUnpause(baseFlags); + + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/projects`, + `GET ${API}/v1/projects/${VALID_REF}/branches`, + `POST ${API}/v1/projects/${BRANCH_REF}/restore`, + `GET ${API}/v1/projects/${VALID_REF}`, + ]); + expect(out.stderrText).toContain(`Selected branch ID: ${BRANCH_REF}`); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "branches unpause", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md index 8146e099e7..6460e4bad6 100644 --- a/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md @@ -6,20 +6,23 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------------------ | -| `/supabase/.temp/linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after the command runs (in `Effect.ensuring`), unless the file already exists, `.temp/project-ref` names a different project, no access token resolves, or the project lookup is not 200 | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | When | Request body | Response | -| ------- | ----------------------------------------------- | ------------ | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------ | -| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | none | `{project_ref}` | -| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | none | `{ref}` | -| `PATCH` | `/v1/branches/{branch_id_or_ref}` | Bearer token | always | `{branch_name?, git_branch?, persistent?, status?, notify_url?}` (only set flags emitted) | full `BranchResponse` | -| `GET` | `/v1/projects/{ref}` (on 4xx gated) | Bearer token | upgrade-suggest path | none | `{organization_slug}` | -| `GET` | `/v1/organizations/{slug}/entitlements` (gated) | Bearer token | upgrade-suggest path | none | `[{feature: {key}, hasAccess, ...}]` | +| Method | Path | Auth | When | Request body | Response | +| ------- | ----------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | none | `{project_ref}` | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | none | `{ref}` | +| `PATCH` | `/v1/branches/{branch_id_or_ref}` | Bearer token | always | `{branch_name?, git_branch?, persistent?, status?, notify_url?}` (only set flags emitted) | full `BranchResponse` | +| `GET` | `/v1/projects/{ref}` (on 4xx gated) | Bearer token | upgrade-suggest path | none | `{organization_slug}` | +| `GET` | `/v1/organizations/{slug}/entitlements` (gated) | Bearer token | upgrade-suggest path | none | `[{feature: {key}, hasAccess, ...}]` | +| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | none | `[{name, project_ref, …}]` | +| `GET` | `/v1/projects` | Bearer token | no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | none | `[{id, name, organization_slug, region, …}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | none | `{ref, name, organization_id, organization_slug}` | ## Environment Variables diff --git a/apps/cli/src/commands/branches/update/update.command.ts b/apps/cli/src/commands/branches/update/update.command.ts index 29c2a265c9..2370bd8293 100644 --- a/apps/cli/src/commands/branches/update/update.command.ts +++ b/apps/cli/src/commands/branches/update/update.command.ts @@ -1,6 +1,13 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { branchPrompt } from "../branches.prompt.ts"; +import { branchRef } from "../../../command-internal/branch-ref.resolver.ts"; +import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -49,6 +56,19 @@ export type BranchesUpdateFlags = CliCommand.Command.Config.Infer export const branchesUpdateCommand = Command.make("update", config).pipe( Command.withDescription("Update a preview branch by its name or ID."), Command.withShortDescription("Update a preview branch"), + withPermissions( + compose( + { + operations: [{ operationId: "v1-update-a-branch-config", kind: "required" }], + noApiEffectFlags: ["name", "git-branch", "persistent", "status", "notify-url"], + }, + projectRefPicker, + branchPrompt, + branchRef, + upgradeSuggestion, + linkedProjectCache, + ), + ), Command.withHandler((flags) => branchesUpdate(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"], config }), diff --git a/apps/cli/src/commands/branches/update/update.integration.test.ts b/apps/cli/src/commands/branches/update/update.integration.test.ts index 255b736596..25d3518c45 100644 --- a/apps/cli/src/commands/branches/update/update.integration.test.ts +++ b/apps/cli/src/commands/branches/update/update.integration.test.ts @@ -1,9 +1,13 @@ -import { type V1UpdateABranchConfigOutput } from "@supabase/api/effect"; +import { + type V1ListAllProjectsOutput, + type V1UpdateABranchConfigOutput, +} from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { + VALID_REF, buildTestRuntime, jsonResponse, mockCommandSettings, @@ -12,6 +16,8 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import type { BranchesUpdateFlags } from "./update.command.ts"; import { branchesUpdate } from "./update.handler.ts"; @@ -76,8 +82,33 @@ const BRANCH_CONFIG = { db_port: 5432, }; +const OTHER_BRANCH_REF = "dddddddddddddddddddd"; +const API = "https://api.supabase.com"; + +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + const tempRoot = useTempWorkdir("supabase-branches-update-int-"); +const sequence = (requests: ReadonlyArray<{ method: string; url: string }>) => + requests.map(({ method, url }) => `${method} ${url}`); + interface SetupOpts { readonly format?: "text" | "json" | "stream-json"; readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml"; @@ -145,7 +176,7 @@ const baseFlags: BranchesUpdateFlags = { describe("branches update integration", () => { it.effect("updates a branch with --name and emits 'Updated preview branch:' to stderr", () => { - const { layer, out } = setup(); + const { layer, out, api } = setup(); return Effect.gen(function* () { yield* branchesUpdate({ ...baseFlags, @@ -154,6 +185,16 @@ describe("branches update integration", () => { }); expect(out.stderrText).toContain("Updated preview branch:"); expect(out.stdoutText).toContain("renamed"); + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/branches/${BRANCH_UUID}`, + `PATCH ${API}/v1/branches/${BRANCH_UUID}`, + ]); + assertPermissionDrift({ + command: "branches update", + activeFlags: { name: true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -175,6 +216,11 @@ describe("branches update integration", () => { persistent: true, notify_url: "https://hook.example.com", }); + assertPermissionDrift({ + command: "branches update", + activeFlags: { name: true, "git-branch": true, persistent: true, "notify-url": true }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); @@ -315,6 +361,16 @@ describe("branches update integration", () => { r.url.endsWith(`/v1/organizations/${ORG_SLUG}/entitlements`), ); expect(entitlementsCall).toBeDefined(); + expect(sequence(apiMock.requests)).toEqual([ + `PATCH ${API}/v1/branches/${BRANCH_REF}`, + `GET ${API}/v1/projects/${BRANCH_REF}`, + `GET ${API}/v1/organizations/${ORG_SLUG}/entitlements`, + ]); + assertPermissionDrift({ + command: "branches update", + activeFlags: { persistent: true }, + requests: apiMock.requests, + }); expect(analytics.captured).toEqual([ { event: "cli_upgrade_suggested", @@ -325,4 +381,74 @@ describe("branches update integration", () => { }).pipe(Effect.provide(layer)); }, ); + + it.effect( + "updates the branch chosen from the pickers and fills the real linked-project cache", + () => { + const out = mockOutput({ format: "text", promptSelectResponses: [VALID_REF, BRANCH_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "GET" && request.url === `${API}/v1/projects`) { + return jsonResponse(request, 200, [PROJECT]); + } + if (request.method === "GET" && request.url === `${API}/v1/projects/${VALID_REF}`) { + return jsonResponse(request, 200, PROJECT); + } + if ( + request.method === "GET" && + request.url === `${API}/v1/projects/${VALID_REF}/branches` + ) { + return jsonResponse(request, 200, [ + { ...UPDATED, name: "other", project_ref: OTHER_BRANCH_REF }, + { ...UPDATED, name: "feat-x" }, + ]); + } + if (request.method === "PATCH" && request.url === `${API}/v1/branches/${BRANCH_REF}`) { + return jsonResponse(request, 200, UPDATED); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* branchesUpdate({ ...baseFlags, name: Option.some("renamed") }); + + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/projects`, + `GET ${API}/v1/projects/${VALID_REF}/branches`, + `PATCH ${API}/v1/branches/${BRANCH_REF}`, + `GET ${API}/v1/projects/${VALID_REF}`, + ]); + expect(out.stderrText).toContain("Updated preview branch:"); + expect(out.stdoutText).toContain("renamed"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "branches update", + activeFlags: { name: true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/config/project-ref.layer.ts b/apps/cli/src/config/project-ref.layer.ts index 1945cfdc84..eed180e0e3 100644 --- a/apps/cli/src/config/project-ref.layer.ts +++ b/apps/cli/src/config/project-ref.layer.ts @@ -25,7 +25,7 @@ export const projectRefPicker = block("project-ref", [ kind: "required", when: [{ flag: "project-ref", present: false }], context: - "an interactive terminal, no SUPABASE_PROJECT_ID and no supabase/.temp/project-ref (`link` ignores that file)", + "an interactive terminal (stdin and stdout are TTYs, output format not json), no SUPABASE_PROJECT_ID and no supabase/.temp/project-ref (`link` ignores that file)", }, ]); diff --git a/apps/cli/src/telemetry/linked-project-cache.layer.ts b/apps/cli/src/telemetry/linked-project-cache.layer.ts index 466d0c0d1c..e86fad92e3 100644 --- a/apps/cli/src/telemetry/linked-project-cache.layer.ts +++ b/apps/cli/src/telemetry/linked-project-cache.layer.ts @@ -16,7 +16,8 @@ export const linkedProjectCache = block("linked-project-cache", [ { operationId: "v1-get-project", kind: "best-effort", - context: "no cached supabase/.temp/linked-project.json", + context: + "an access token, no cached supabase/.temp/linked-project.json, and no supabase/.temp/project-ref naming a different project", }, ]); diff --git a/apps/cli/tests/helpers/permission-pending-commands.ts b/apps/cli/tests/helpers/permission-pending-commands.ts index 1f68150661..1758a6972d 100644 --- a/apps/cli/tests/helpers/permission-pending-commands.ts +++ b/apps/cli/tests/helpers/permission-pending-commands.ts @@ -1,19 +1,7 @@ /** Leaf command paths that don't declare their permissions with `withPermissions` yet. */ export const PENDING_COMMANDS: ReadonlyArray = [ - "backups list", - "backups restore", - "bootstrap", - "branches create", - "branches delete", - "branches disable", - "branches get", - "branches list", - "branches pause", - "branches unpause", - "branches update", - "completion bash", "completion fish", "completion powershell", From a5947f00f677813b4a3245bb1fb58ffd63a99c4f Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Thu, 15 Oct 2026 07:38:08 +0000 Subject: [PATCH 08/26] chore(cli): map config diff, pull and push command permissions (#7168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps the Management API operations behind `config diff`, `config pull` and `config push`, so scoped access tokens can be given matching permissions. This changes no command behaviour. **Commands:** `config diff`, `config pull`, `config push` (removed from the pending list). **New shared blocks** - `configTarget` (`project-target.ts`): the branch lookup for a non-ref `--project-ref` (`v1-get-a-branch-config` for a UUID, `v1-get-a-branch` for a name), else the project picker. Reuses `branchRef` and `projectRefPicker`. - `configPullRun` (`config-pull-run.ts`): the remote config read `v2-get-project-config`, shared with top-level `pull`. **Declarations** - `diff`: `configTarget`, the config read, linked-project cache. - `pull`: `configTarget`, `configPullRun`, linked-project cache. - `push`: `configTarget`; the target probe (best-effort); the add-ons list and config read (required); one required write per resource (api, db settings, network restrictions, SSL enforcement, auth, storage, webhooks), each with a context naming what triggers it; the branch listing (best-effort); linked-project cache. **Conditions:** `--project-ref` present gates the branch lookups. Write triggers, the probe and the branch listing are contexts, since they depend on `config.toml` contents and `.temp` state rather than flags. **Worth knowing** - `config push` always needs the add-ons read, even when nothing is billable. - Which writes happen depends on which sections the token can read. - Not covered by a drift check: `push`'s cache inclusion (its project probe shares an operation), and the write contexts, which are asserted by request. **Docs:** three wrong rows in `config push/SIDE_EFFECTS.md` corrected (branch lookup parent, cost matrix order, webhooks key). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 --- .../src/command-internal/config-pull-run.ts | 6 + .../src/command-internal/project-target.ts | 10 +- .../src/commands/config/diff/diff.command.ts | 14 ++ .../config/diff/diff.integration.test.ts | 116 ++++++++++- .../src/commands/config/pull/pull.command.ts | 13 ++ .../config/pull/pull.integration.test.ts | 133 ++++++++++-- .../src/commands/config/push/SIDE_EFFECTS.md | 6 +- .../src/commands/config/push/push.command.ts | 63 ++++++ .../config/push/push.integration.test.ts | 196 ++++++++++++++---- .../helpers/permission-pending-commands.ts | 4 - 10 files changed, 496 insertions(+), 65 deletions(-) diff --git a/apps/cli/src/command-internal/config-pull-run.ts b/apps/cli/src/command-internal/config-pull-run.ts index 8327855dba..5586e782e7 100644 --- a/apps/cli/src/command-internal/config-pull-run.ts +++ b/apps/cli/src/command-internal/config-pull-run.ts @@ -22,6 +22,7 @@ import { Effect, FileSystem, Result, Schema, SchemaIssue } from "effect"; import { CommandPlatformApi } from "../auth/command-platform-api.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; import { Output } from "../shared/output/output.service.ts"; +import { block } from "./command-permissions/model.ts"; import { sanitizeErrorBody, sanitizeInlineName } from "./http-errors.ts"; import type { ConfigTarget } from "./project-target.ts"; import { BRANCH_UUID_PATTERN } from "./ref-patterns.ts"; @@ -589,6 +590,11 @@ export const openConfigPullSource = Effect.fn("ConfigPull.openSource")(function* return { loaded, text: loaded.rawText }; }); +/** `planConfigPullRun`'s remote config read, shared by `config pull` and `pull`. */ +export const configPullRun = block("config-pull-run", [ + { operationId: "v2-get-project-config", kind: "required" }, +]); + /** * Resolves the pull destination, reloads with the `[remotes.*]` overlay when block reuse * selects an existing block, fetches the remote config, and runs the fixpoint-expanded diff --git a/apps/cli/src/command-internal/project-target.ts b/apps/cli/src/command-internal/project-target.ts index 1dac052d46..9505cc40b3 100644 --- a/apps/cli/src/command-internal/project-target.ts +++ b/apps/cli/src/command-internal/project-target.ts @@ -10,7 +10,9 @@ import { resolveParentScopedProjectRef, } from "./parent-project-ref.ts"; import { Output } from "../shared/output/output.service.ts"; -import { resolveBranchProjectRef } from "./branch-ref.resolver.ts"; +import { projectRefPicker } from "../config/project-ref.layer.ts"; +import { branchRef, resolveBranchProjectRef } from "./branch-ref.resolver.ts"; +import { compose, withCondition } from "./command-permissions/model.ts"; import { sanitizeInlineName } from "./http-errors.ts"; import { BRANCH_PROJECT_REF_PATTERN, BRANCH_UUID_PATTERN } from "./ref-patterns.ts"; import { @@ -148,6 +150,12 @@ function reclassifyBranchNotFoundError( return cause.status === 404 ? Effect.fail(notFoundError) : Effect.fail(cause); } +/** The calls `resolveConfigTarget` makes: a branch lookup for a non-ref `--project-ref`, the project picker when no ref is given or linked. */ +export const configTarget = compose( + withCondition(branchRef, { flag: "project-ref", present: true }), + projectRefPicker, +); + /** * Resolves `--project-ref` to a {@link ConfigTarget}: a project ref, or the name/UUID of a * branch on the linked project. A ref-shaped value (20 lowercase letters) is always a project ref. diff --git a/apps/cli/src/commands/config/diff/diff.command.ts b/apps/cli/src/commands/config/diff/diff.command.ts index b81367f6ec..9856559bcd 100644 --- a/apps/cli/src/commands/config/diff/diff.command.ts +++ b/apps/cli/src/commands/config/diff/diff.command.ts @@ -3,6 +3,10 @@ import type * as CliCommand from "effect/cli/Command"; import { Command, Flag } from "effect/cli"; import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { configTarget } from "../../../command-internal/project-target.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { GLOBAL_OUTPUT_FORMATS } from "../../../command-internal/global-flags.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; @@ -62,6 +66,16 @@ export const configDiffCommand = Command.make("diff", config).pipe( description: "Diff against the 'staging' branch, exiting 2 on drift", }, ]), + withPermissions( + compose( + configTarget, + { + operations: [{ operationId: "v2-get-project-config", kind: "required" }], + noApiEffectFlags: ["exit-code"], + }, + linkedProjectCache, + ), + ), Command.withHandler(configDiffHandler), Command.provide(managementApiRuntimeLayer(["config", "diff"])), ); diff --git a/apps/cli/src/commands/config/diff/diff.integration.test.ts b/apps/cli/src/commands/config/diff/diff.integration.test.ts index 9754d107c6..813bcf0966 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -1,4 +1,5 @@ import { BunServices } from "@effect/platform-bun"; +import { type V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Schema, Stdio } from "effect"; import { vi } from "vitest"; @@ -8,6 +9,7 @@ import { mockOutput, mockProcessControl, mockRuntimeInfo, + mockTty, } from "../../../../tests/helpers/mocks.ts"; import { v2ProjectConfigResponse } from "../../../../tests/helpers/config-fixtures.ts"; import { @@ -22,6 +24,8 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { GLOBAL_OUTPUT_FORMATS } from "../../../command-internal/global-flags.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { configDiffHandler } from "./diff.command.ts"; @@ -35,6 +39,7 @@ const BACKDATED_MTIME_SECONDS = 1_577_836_800; const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; const BRANCH_REF = "cccccccccccccccccccc"; +const API = "https://api.supabase.com"; const writeProjectFile = Effect.fnUntraced(function* (name: string, contents: string) { const fs = yield* FileSystem.FileSystem; @@ -45,6 +50,7 @@ const writeProjectFile = Effect.fnUntraced(function* (name: string, contents: st }); const jsonText = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeJsonText = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); /** Schema-valid v2 project-config body whose managed values all sit at the local schema * defaults, so an empty config.toml diffs clean against it. */ @@ -54,6 +60,23 @@ const jsonRecord = Schema.Record(Schema.String, Schema.Unknown); const asJsonRecord = Schema.decodeUnknownSync(jsonRecord); const decodeJsonRecord = Schema.decodeUnknownEffect(jsonRecord); +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + /** V1GetABranch body for the branch-name `--project-ref` lookup. */ const BRANCH_BY_NAME = { id: BRANCH_UUID, @@ -164,7 +187,7 @@ const noFlags = { describe("config diff integration", () => { it.effect("reports drift against the linked project without touching the config file", () => { - const { layer, out, processControl, telemetry, linkedProjectCache } = setup({ + const { layer, out, processControl, telemetry, linkedProjectCache, api } = setup({ toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', }); return Effect.gen(function* () { @@ -194,6 +217,7 @@ describe("config diff integration", () => { expect(processControl.exitCode).toBeUndefined(); expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cachedRef).toBe(VALID_REF); + assertPermissionDrift({ command: "config diff", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -275,12 +299,18 @@ describe("config diff integration", () => { }); it.effect("--exit-code sets exit 2 when differences are found", () => { - const { layer, processControl } = setup({ + const { layer, processControl, api } = setup({ toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', }); return Effect.gen(function* () { yield* configDiff({ ...noFlags, exitCode: true }); expect(processControl.exitCode).toBe(2); + assertPermissionDrift({ + command: "config diff", + activeFlags: { "exit-code": true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -482,6 +512,11 @@ describe("config diff integration", () => { true, ); expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); + assertPermissionDrift({ + command: "config diff", + activeFlags: { "project-ref": true }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); @@ -511,6 +546,11 @@ describe("config diff integration", () => { true, ); expect(urls.some((url) => url.includes(`/v1/projects/${BRANCH_REF}/`))).toBe(false); + assertPermissionDrift({ + command: "config diff", + activeFlags: { "project-ref": true }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }, ); @@ -530,6 +570,11 @@ describe("config diff integration", () => { expect(out.stderrText).toContain( `Comparing against branch ${BRANCH_UUID} (project ref ${BRANCH_REF})`, ); + assertPermissionDrift({ + command: "config diff", + activeFlags: { "project-ref": true }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); @@ -543,6 +588,12 @@ describe("config diff integration", () => { const urls = api.requests.map((request) => request.url); expect(urls.some((url) => url.includes("/branches/"))).toBe(false); expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); + assertPermissionDrift({ + command: "config diff", + activeFlags: { "project-ref": true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -811,7 +862,7 @@ describe("config diff integration", () => { }); it.effect("a remote config error status maps to the read status error", () => { - const { layer } = setup({ + const { layer, api } = setup({ toml: 'project_id = "test"\n', v2: { status: 403, body: { message: "forbidden" } }, }); @@ -825,6 +876,10 @@ describe("config diff integration", () => { expect(causeText).toContain("Access denied"); expect(causeText).toContain(VALID_REF); } + expect( + api.requests.map((request) => `${request.method} ${new URL(request.url).pathname}`), + ).toEqual([`GET /v2/projects/${VALID_REF}/config`]); + assertPermissionDrift({ command: "config diff", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -1225,6 +1280,61 @@ describe("config diff integration", () => { expect(api.requests.some((request) => request.url.includes("/v2/projects/"))).toBe(false); }).pipe(Effect.provide(layer)); }); + + it.effect( + "diffs the project chosen from the picker and fills the real linked-project cache", + () => { + const out = mockOutput({ format: "text", promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "GET" && request.url === `${API}/v1/projects`) { + return jsonResponse(request, 200, [PROJECT]); + } + if (request.method === "GET" && request.url === `${API}/v1/projects/${VALID_REF}`) { + return jsonResponse(request, 200, PROJECT); + } + if (request.url === `${API}/v2/projects/${VALID_REF}/config`) { + return jsonResponse(request, 200, v2Response()); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* writeProjectFile("config.toml", 'project_id = "test"\n'); + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* configDiff(noFlags); + + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${API}/v1/projects`, + `GET ${API}/v2/projects/${VALID_REF}/config`, + `GET ${API}/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain("No config differences found."); + expect(yield* decodeJsonText(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "config diff", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); }); describe("config diff telemetry wiring", () => { diff --git a/apps/cli/src/commands/config/pull/pull.command.ts b/apps/cli/src/commands/config/pull/pull.command.ts index e9791ab60c..708f92f7d3 100644 --- a/apps/cli/src/commands/config/pull/pull.command.ts +++ b/apps/cli/src/commands/config/pull/pull.command.ts @@ -3,6 +3,11 @@ import type * as CliCommand from "effect/cli/Command"; import { Command, Flag } from "effect/cli"; import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { configPullRun } from "../../../command-internal/config-pull-run.ts"; +import { configTarget } from "../../../command-internal/project-target.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { GLOBAL_OUTPUT_FORMATS } from "../../../command-internal/global-flags.ts"; @@ -75,6 +80,14 @@ export const configPullCommand = Command.make("pull", config).pipe( description: "Preview the changes without writing the config file", }, ]), + withPermissions( + compose( + configTarget, + configPullRun, + { operations: [], noApiEffectFlags: ["remote-label", "dry-run", "force"] }, + linkedProjectCache, + ), + ), Command.withHandler(configPullHandler), // `stdinLayer`: the apply confirmation reads piped stdin via `promptYesNo` // on a non-TTY stdin. diff --git a/apps/cli/src/commands/config/pull/pull.integration.test.ts b/apps/cli/src/commands/config/pull/pull.integration.test.ts index 2e9ae4916a..a6748c3818 100644 --- a/apps/cli/src/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/config/pull/pull.integration.test.ts @@ -1,4 +1,5 @@ import { BunServices } from "@effect/platform-bun"; +import { type V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Cause, @@ -37,6 +38,12 @@ import { useTempWorkdir, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; +import { + assertBlockDrift, + assertPermissionDrift, +} from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; +import { configPullRun } from "../../../command-internal/config-pull-run.ts"; import { GLOBAL_OUTPUT_FORMATS, YesFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { @@ -55,6 +62,7 @@ const tempRoot = useTempWorkdir("supabase-config-pull-int-"); const jsonRecord = Schema.Record(Schema.String, Schema.Unknown); const asJsonRecord = Schema.decodeUnknownSync(jsonRecord); const decodeJsonRecord = Schema.decodeUnknownEffect(jsonRecord); +const decodeJsonText = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); const decodeJsonRecords = Schema.decodeUnknownEffect(Schema.Array(jsonRecord)); const decodeChanges = Schema.decodeUnknownEffect( Schema.Array( @@ -65,9 +73,27 @@ const decodeChanges = Schema.decodeUnknownEffect( // A past mtime for "untouched file" checks, so a rewrite within the same millisecond still shows. const BACKDATED_MTIME_SECONDS = 1_577_836_800; +const API = "https://api.supabase.com"; const BRANCH_REF = "cccccccccccccccccccc"; const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + const projectFilePath = Effect.fnUntraced(function* (name: string) { const path = yield* Path.Path; return path.join(tempRoot.current, "supabase", name); @@ -347,6 +373,9 @@ interface SetupOpts { readonly linked?: boolean; /** Overrides `cliSettings.projectId` directly — takes precedence over `linked`. */ readonly projectId?: Option.Option; + /** Serves the project list and project lookup, answers the project picker with `VALID_REF`, and + * writes the linked-project cache through the real cache layer. */ + readonly pickerAndRealCache?: boolean; readonly yes?: boolean; readonly stdinIsTty?: boolean; readonly confirm?: ReadonlyArray; @@ -368,7 +397,11 @@ function setup(opts: SetupOpts = {}) { opts.dotenv === undefined ? Effect.void : writeProjectFile(".env", opts.dotenv), ]), ).pipe(Layer.provide(BunServices.layer)); - const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); + const out = mockOutput({ + format: opts.format ?? "text", + promptConfirmResponses: opts.confirm, + ...(opts.pickerAndRealCache === true ? { promptSelectResponses: [VALID_REF] } : {}), + }); const outputLayer = opts.confirmSideEffect === undefined ? out.layer @@ -376,6 +409,13 @@ function setup(opts: SetupOpts = {}) { const api = mockCommandPlatformApi({ handler: (request) => { const url = request.url; + if (opts.pickerAndRealCache === true && request.method === "GET") { + if (url === `${API}/v1/projects`) + return Effect.succeed(jsonResponse(request, 200, [PROJECT])); + if (url === `${API}/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, PROJECT)); + } + } if (url.includes("/v2/projects/")) { if (opts.v2 === "fail") { return Effect.fail(transportFailure(request)); @@ -411,6 +451,15 @@ function setup(opts: SetupOpts = {}) { const telemetry = mockTelemetryStateTracked(); const linkedProjectCache = mockLinkedProjectCacheTracked(); const processControl = mockProcessControl(); + const cliSettings = mockCommandSettings({ + workdir: opts.workdir ?? tempRoot.current, + explicitWorkdir: opts.explicitWorkdir ?? false, + ...(opts.projectId !== undefined + ? { projectId: opts.projectId } + : opts.linked === false + ? { projectId: Option.none() } + : {}), + }); const gitStatus = mockGitStatusSpawner({ dirty: opts.gitDirty, spawnFails: opts.gitSpawnFails, @@ -419,18 +468,13 @@ function setup(opts: SetupOpts = {}) { buildTestRuntime({ out: { layer: outputLayer }, api, - cliSettings: mockCommandSettings({ - workdir: opts.workdir ?? tempRoot.current, - explicitWorkdir: opts.explicitWorkdir ?? false, - ...(opts.projectId !== undefined - ? { projectId: opts.projectId } - : opts.linked === false - ? { projectId: Option.none() } - : {}), - }), + cliSettings, runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), telemetry: telemetry.layer, - linkedProjectCache: linkedProjectCache.layer, + linkedProjectCache: + opts.pickerAndRealCache === true + ? realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }) + : linkedProjectCache.layer, processControl, tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput), @@ -465,7 +509,10 @@ describe("config pull integration", () => { "root-target single-property update writes the file with exactly one changed line", () => { const before = 'project_id = "test"\n[api]\nmax_rows = 500\n'; - const { layer, out, telemetry, linkedProjectCache } = setup({ toml: before, yes: true }); + const { layer, out, telemetry, linkedProjectCache, api } = setup({ + toml: before, + yes: true, + }); return Effect.gen(function* () { yield* configPull(noFlags); const after = yield* readConfig; @@ -475,13 +522,48 @@ describe("config pull integration", () => { expect(out.stdoutText).toContain("1 change written."); expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cachedRef).toBe(VALID_REF); + assertPermissionDrift({ command: "config pull", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "pulls from the project chosen in the picker and fills the real linked-project cache", + () => { + const before = 'project_id = "test"\n[api]\nmax_rows = 500\n'; + const { layer, out, api } = setup({ + toml: before, + yes: true, + linked: false, + stdinIsTty: true, + pickerAndRealCache: true, + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* configPull(noFlags); + + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${API}/v1/projects`, + `GET ${API}/v2/projects/${VALID_REF}/config`, + `GET ${API}/v1/projects/${VALID_REF}`, + ]); + expect(yield* readConfig).toContain("max_rows = 1000"); + expect(out.stdoutText).toContain("1 change written."); + expect(yield* decodeJsonText(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "config pull", requests: api.requests, exact: true }); + assertBlockDrift(configPullRun, { requests: api.requests.slice(1, 2), exact: true }); }).pipe(Effect.provide(layer)); }, ); it.effect("--dry-run leaves the file untouched and reports dry_run in the payload", () => { const before = 'project_id = "test"\n[api]\nmax_rows = 500\n'; - const { layer, out } = setup({ toml: before, format: "json", yes: true }); + const { layer, out, api } = setup({ toml: before, format: "json", yes: true }); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* projectFilePath("config.toml"); @@ -496,6 +578,12 @@ describe("config pull integration", () => { expect(data["wrote"]).toBe(false); const changes = yield* decodeJsonRecords(data["changes"]); expect(changes.every((change) => change["written"] === false)).toBe(true); + assertPermissionDrift({ + command: "config pull", + activeFlags: { "dry-run": true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -1591,6 +1679,11 @@ describe("config pull integration", () => { true, ); expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); + assertPermissionDrift({ + command: "config pull", + activeFlags: { "project-ref": true }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); @@ -1621,6 +1714,11 @@ describe("config pull integration", () => { // A UUID target creates a new `[remotes.*]` block falling back to the // resolved project ref as the label (no branch name to use instead). expect(yield* readConfig).toContain(`[remotes.${BRANCH_REF}]`); + assertPermissionDrift({ + command: "config pull", + activeFlags: { "project-ref": true }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); @@ -1635,6 +1733,12 @@ describe("config pull integration", () => { const urls = api.requests.map((request) => request.url); expect(urls.some((url) => url.includes("/branches/"))).toBe(false); expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); + assertPermissionDrift({ + command: "config pull", + activeFlags: { "project-ref": true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -1822,7 +1926,7 @@ describe("config pull integration", () => { }); it.effect("a 403 on the config read names the sanitized ref", () => { - const { layer } = setup({ + const { layer, api } = setup({ toml: 'project_id = "test"\n', v2: { status: 403, body: { message: "forbidden" } }, }); @@ -1835,6 +1939,7 @@ describe("config pull integration", () => { expect(causeText).toContain("Access denied"); expect(causeText).toContain(VALID_REF); } + assertPermissionDrift({ command: "config pull", requests: api.requests }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index 312dc1bd78..30424ddcbd 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -44,8 +44,8 @@ declines the confirmation prompt. | -2 | branch resolution (name) | GET | `/v1/projects/{parent_ref}/branches/{name}` | 200 | only when `--project-ref` names a branch by name (CLI-2289); `parent_ref` from the currently linked project | | -2 | branch resolution (UUID) | GET | `/v1/branches/{id}` | 200 | only when `--project-ref` is a UUID (CLI-2289); needs no linked project | | -1 | target detection | GET | `/v1/projects/{ref}` | 200 or 404 | CLI-2168: 200 = plain project (its `name` is shown); 404 = `ref` is a preview branch. Wrapped in a `"Checking project..."` task and bounded at 5s. Entirely best-effort/diagnostic: a TIMEOUT, a transport failure, or any OTHER status degrades to an "unknown" target (never silently "project" — that would skip the confirmation gate for a real branch — and never a hard failure that aborts an otherwise-successful push, e.g. for a scoped token that can write service config but can't read the project record). Skipped entirely when `--project-ref` already named a branch by name/UUID above. | -| -1 | branch name lookup | GET | `/v1/projects/{parent_ref}/branches` | 200 | only on the 404 path above, or a UUID-resolved target (never when the probe degrades to "unknown" — that never attempts recovery) — and only when a candidate parent is known (from `--project-ref` or `.temp/linked-project.json`); best-effort, 5s-bounded — a failure only omits the branch's name/parent from the echoed message, it never fails the command | -| 0 | cost matrix | GET | `/v1/projects/{ref}/billing/addons` | 200 | typed client; cost map for 1-variant addons; unchanged, still fetched first | +| -1 | branch name lookup | GET | `/v1/projects/{parent_ref}/branches` | 200 | only on the 404 path above, or a UUID-resolved target (never when the probe degrades to "unknown" — that never attempts recovery) — and only when `.temp/linked-project.json` names a different, ref-shaped project to treat as the parent (a branch-name `--project-ref` never triggers it); best-effort, 5s-bounded — a failure only omits the branch's name/parent from the echoed message, it never fails the command | +| 0 | cost matrix | GET | `/v1/projects/{ref}/billing/addons` | 200 | typed client; cost map for 1-variant addons; fetched after the target probe and the branch confirmation gate | | 1 | effective project config | GET | `/v2/projects/{ref}/config` | 200 | the ONLY read this command makes — see below | | 2 | api | PATCH | `/v1/projects/{ref}/postgrest` | 200 | only when `api` has a pushable change, its response block was returned, and the change is kept | | 3 | db.settings | PUT | `/v1/projects/{ref}/config/database/postgres` | 200 | same conditions (this resource's own local gate is "always on") | @@ -53,7 +53,7 @@ declines the confirmation prompt. | 5 | db.ssl_enforcement | PUT | `/v1/projects/{ref}/ssl-enforcement` | 200 | same conditions, plus `[db.ssl_enforcement]` declared locally | | 6 | auth | PATCH | `/v1/projects/{ref}/config/auth` | 2xx | same conditions (this resource's own local gate is "always on" too, CLI-2314 — `auth.enabled` only controls the local GoTrue Docker service, not this write); MFA phone/webauthn gated by an addon cost prompt when `verify_enabled` or `enroll_enabled` is being turned on and the add-on is not already active on the project | | 7 | storage | PATCH | `/v1/projects/{ref}/config/storage` | 2xx | same conditions (this resource's own local gate is "always on" too, CLI-2314 — `storage.enabled` only controls the local Storage Docker service, not this write) | -| 8 | experimental.webhooks | POST | `/v1/projects/{ref}/database/webhooks/enable` | 2xx | only if local `webhooks.enabled`; no GET/diff; a kept enable reports `changes: [["experimental","webhooks","enabled"]]` (no config-diff path backs it) so it still counts toward the pushed-property total | +| 8 | experimental.webhooks | POST | `/v1/projects/{ref}/database/webhooks/enable` | 2xx | only if local `experimental.webhooks.enabled`; no GET/diff; a kept enable reports `changes: [["experimental","webhooks","enabled"]]` (no config-diff path backs it) so it still counts toward the pushed-property total | **Row 1 is the only per-resource `GET` this command makes** — no per-service `GET /v1/…` request remains anywhere in this table; row 1's single response diff --git a/apps/cli/src/commands/config/push/push.command.ts b/apps/cli/src/commands/config/push/push.command.ts index ee172e7858..f4b1626fab 100644 --- a/apps/cli/src/commands/config/push/push.command.ts +++ b/apps/cli/src/commands/config/push/push.command.ts @@ -4,6 +4,11 @@ import { Command, Flag } from "effect/cli"; import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withContext } from "../../../command-internal/command-permissions/model.ts"; +import { branchTarget } from "../../../command-internal/branch-target.ts"; +import { configTarget } from "../../../command-internal/project-target.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; @@ -58,6 +63,64 @@ export const configPushCommand = Command.make("push", config).pipe( description: "Push local config to the 'staging' branch", }, ]), + withPermissions( + compose( + configTarget, + { + operations: [ + { + operationId: "v1-get-project", + kind: "best-effort", + context: "--project-ref is absent, empty or a 20-letter project ref (the branch probe)", + }, + { operationId: "v1-list-project-addons", kind: "required" }, + { operationId: "v2-get-project-config", kind: "required" }, + { + operationId: "v1-update-postgrest-service-config", + kind: "required", + context: "config.toml's [api] settings differ from the remote's", + }, + { + operationId: "v1-update-postgres-config", + kind: "required", + context: "config.toml's [db.settings] differ from the remote's", + }, + { + operationId: "v1-update-network-restrictions", + kind: "required", + context: + "config.toml sets [db.network_restrictions] enabled = true and its rules differ from the remote's", + }, + { + operationId: "v1-update-ssl-enforcement-config", + kind: "required", + context: "config.toml declares [db.ssl_enforcement] and it differs from the remote's", + }, + { + operationId: "v1-update-auth-service-config", + kind: "required", + context: + "config.toml's [auth] settings, a referenced email template file, or a resolved auth secret differ from the remote's", + }, + { + operationId: "v1-update-storage-config", + kind: "required", + context: "config.toml's [storage] settings differ from the remote's", + }, + { + operationId: "v1-enable-database-webhook", + kind: "required", + context: "config.toml sets [experimental.webhooks] enabled = true", + }, + ], + }, + withContext( + branchTarget, + "the target is a branch (a UUID --project-ref, or the probe returned 404) and supabase/.temp/linked-project.json names a different project", + ), + linkedProjectCache, + ), + ), Command.withHandler(configPushHandler), Command.provide(Layer.mergeAll(managementApiRuntimeLayer(["config", "push"]), stdinLayer)), ); diff --git a/apps/cli/src/commands/config/push/push.integration.test.ts b/apps/cli/src/commands/config/push/push.integration.test.ts index e9729929c4..2617eee123 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -38,6 +38,8 @@ import { } from "../../../../tests/helpers/command-mocks.ts"; import { v2ProjectConfigResponse } from "../../../../tests/helpers/config-fixtures.ts"; import { mockRuntimeInfo, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { YesFlag } from "../../../command-internal/global-flags.ts"; import { cliConfigProviderLayer } from "../../../shared/config/cli-config-provider.layer.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; @@ -48,6 +50,7 @@ import { configPushHandler } from "./push.command.ts"; const tempRoot = useTempWorkdir("supabase-config-push-int-"); const REF = VALID_REF; +const API = "https://api.supabase.com"; const workdirPath = Effect.fnUntraced(function* (...segments: ReadonlyArray) { const path = yield* Path.Path; @@ -85,6 +88,7 @@ const workdirFilesLayer = (toml: string, seed: WorkdirSeed = Effect.void) => ).pipe(Layer.provide(BunServices.layer)); const jsonText = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeJsonText = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); const v1TransportFailure = (description: string) => Effect.fail(transportFailure(HttpClientRequest.get(DEFAULT_API_URL), description)); @@ -281,10 +285,14 @@ function setup(opts: { readonly projectId?: Option.Option; /** Extra workdir files (linked-project state) written alongside `config.toml`. */ readonly seed?: WorkdirSeed; + /** Answers the project picker with `REF` and writes the linked-project cache through the real + * cache layer. */ + readonly pickerAndRealCache?: boolean; }) { const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm, + ...(opts.pickerAndRealCache === true ? { promptSelectResponses: [REF] } : {}), promptConfirmFail: opts.promptFail, interactive: opts.interactive ?? ((opts.format ?? "text") === "text" && (opts.stdinIsTty ?? true)), @@ -345,7 +353,23 @@ function setup(opts: { const p = opts.authPatch ?? { status: 200, body: authWriteResponseFixture() }; return Effect.succeed(jsonResponse(request, p.status, p.body)); } + if (url.includes("/network-restrictions/apply")) { + return Effect.succeed( + jsonResponse(request, 200, { entitlement: "allowed", config: {}, status: "applied" }), + ); + } + if (url.includes("/ssl-enforcement")) { + return Effect.succeed( + jsonResponse(request, 200, { + currentConfig: { database: true }, + appliedSuccessfully: true, + }), + ); + } const pathname = new URL(url).pathname; + if (pathname === "/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [PUSH_TEST_PROJECT])); + } // Live target-detection probe: a bare project ref defaults to a schema-valid, unnamed // project, so plain-project scenarios keep working without opting in. if (/^\/v1\/projects\/[a-z0-9-]+$/.test(pathname)) { @@ -372,25 +396,28 @@ function setup(opts: { const b = opts.branchById ?? { status: 404, body: {} }; return Effect.succeed(jsonResponse(request, b.status, b.body)); } - // Anything else (network-restrictions/ssl/webhooks) — succeed with an empty body; - // scenarios writing to one of those use `setupService()` instead. + // Anything else (webhooks) — succeed with an empty body. return Effect.succeed(jsonResponse(request, 200, {})); }, }); const telemetry = mockTelemetryStateTracked(); const linkedProjectCache = mockLinkedProjectCacheTracked(); + const cliSettings = mockCommandSettings({ + workdir: opts.workdir ?? tempRoot.current, + explicitWorkdir: opts.explicitWorkdir ?? false, + ...(opts.projectId === undefined ? {} : { projectId: opts.projectId }), + }); const layer = Layer.mergeAll( buildTestRuntime({ out, api, - cliSettings: mockCommandSettings({ - workdir: opts.workdir ?? tempRoot.current, - explicitWorkdir: opts.explicitWorkdir ?? false, - ...(opts.projectId === undefined ? {} : { projectId: opts.projectId }), - }), + cliSettings, runtimeInfo: mockRuntimeInfo({ cwd: opts.runtimeCwd ?? tempRoot.current }), telemetry: telemetry.layer, - linkedProjectCache: linkedProjectCache.layer, + linkedProjectCache: + opts.pickerAndRealCache === true + ? realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }) + : linkedProjectCache.layer, tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? true, stdoutIsTty: @@ -532,7 +559,7 @@ schemas = ["public", "remote_schema"] }); it.effect("fails when listing addons returns 503", () => { - const { layer } = setup({ + const { layer, api } = setup({ toml: `project_id = "test"\n`, yes: true, addons: { status: 503, body: {} }, @@ -540,6 +567,9 @@ schemas = ["public", "remote_schema"] return Effect.gen(function* () { const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); + + expect(api.requests.some((r) => r.url.includes("/v2/projects/"))).toBe(false); + assertPermissionDrift({ command: "config push", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -1046,6 +1076,8 @@ max_rows = 1000 changes: [["api", "schemas"]], }); expect(data["remote_only"]).toBe(1); + + assertPermissionDrift({ command: "config push", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -1082,6 +1114,8 @@ statement_timeout = "8s" ); expect(put).toBeDefined(); expect(put?.body).toEqual({ statement_timeout: "8s" }); + + assertPermissionDrift({ command: "config push", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -1114,6 +1148,8 @@ max_frequency = "10s" sessions_timebox: 24, mfa_phone_max_frequency: 10, }); + + assertPermissionDrift({ command: "config push", requests: api.requests }); }).pipe(Effect.provide(layer)); }, ); @@ -1359,6 +1395,7 @@ otp_expiry = 120 const success = out.messages.find((m) => m.type === "success"); const data = yield* decodeJsonRecord(success?.data); expect(data["remote_only"]).toBe(1); + assertPermissionDrift({ command: "config push", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); }); @@ -1712,6 +1749,8 @@ secret = "${DOTENVX_ENCRYPTED_VALUE}" ); expect(update).toBeDefined(); expect(update?.body).toEqual({ fileSizeLimit: 104857600 }); + + assertPermissionDrift({ command: "config push", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -1721,35 +1760,102 @@ enabled = true allowed_cidrs = ["1.2.3.4/32"] allowed_cidrs_v6 = ["::1/128"] `; - const { layer, apiMock } = setupService({ - toml, - yes: true, - v1: { updateNetworkRestrictions: () => Effect.succeed({}) }, - }); + const { layer, api } = setup({ toml, yes: true }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - const update = apiMock.requests.find((r) => r.method === "updateNetworkRestrictions"); - expect(update).toBeDefined(); - expect(update?.input).toEqual({ - ref: REF, + const apply = api.requests.find( + (r) => r.method === "POST" && r.url.includes("/network-restrictions/apply"), + ); + expect(apply?.body).toEqual({ dbAllowedCidrs: ["1.2.3.4/32"], dbAllowedCidrsV6: ["::1/128"], }); + assertPermissionDrift({ command: "config push", requests: api.requests }); }).pipe(Effect.provide(layer)); }); it.effect("pushes db.ssl_enforcement only when declared in config", () => { - const toml = `${BASE_DISABLED}[db.ssl_enforcement]\nenabled = true\n`; - const { layer, apiMock } = setupService({ - toml, + const { layer, api } = setup({ + toml: `${BASE_DISABLED}[db.ssl_enforcement]\nenabled = true\n`, yes: true, - v1: { updateSslEnforcementConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - const update = apiMock.requests.find((r) => r.method === "updateSslEnforcementConfig"); - expect(update).toBeDefined(); - expect(update?.input).toEqual({ ref: REF, requestedConfig: { database: true } }); + const update = api.requests.find( + (r) => r.method === "PUT" && r.url.includes("/ssl-enforcement"), + ); + expect(update?.body).toEqual({ requestedConfig: { database: true } }); + assertPermissionDrift({ command: "config push", requests: api.requests }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("enables database webhooks when experimental.webhooks is enabled (no diff)", () => { + const { layer, out, api } = setup({ + toml: `${BASE_DISABLED}[experimental.webhooks]\nenabled = true\n`, + yes: true, + }); + return Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain("Enabling webhooks for project:"); + expect( + api.requests.some( + (r) => + r.method === "POST" && r.url.includes(`/v1/projects/${REF}/database/webhooks/enable`), + ), + ).toBe(true); + assertPermissionDrift({ command: "config push", requests: api.requests }); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "pushes to the project chosen in the picker and fills the real linked-project cache", + () => { + const { layer, out, api } = setup({ + toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`, + yes: true, + projectId: Option.none(), + pickerAndRealCache: true, + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = yield* workdirPath("supabase", ".temp", "linked-project.json"); + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* configPush({ projectRef: Option.none() }); + + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${API}/v1/projects`, + `GET ${API}/v1/projects/${REF}`, + `GET ${API}/v1/projects/${REF}/billing/addons`, + `GET ${API}/v2/projects/${REF}/config`, + `PATCH ${API}/v1/projects/${REF}/postgrest`, + `GET ${API}/v1/projects/${REF}`, + ]); + expect(out.stderrText).toContain(`Pushing config to project: ${REF}`); + expect(yield* decodeJsonText(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: REF, + }); + assertPermissionDrift({ command: "config push", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("a 403 on the target probe still pushes", () => { + const { layer, out, api } = setup({ + toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`, + yes: true, + project: { status: 403, body: { message: "forbidden" } }, + }); + return Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${API}/v1/projects/${REF}`, + `GET ${API}/v1/projects/${REF}/billing/addons`, + `GET ${API}/v2/projects/${REF}/config`, + `PATCH ${API}/v1/projects/${REF}/postgrest`, + ]); + expect(out.stderrText).toContain("could not determine whether this is a branch"); + assertPermissionDrift({ command: "config push", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -1773,20 +1879,6 @@ allowed_cidrs_v6 = ["::1/128"] }).pipe(Effect.provide(layer)); }); - it.effect("enables webhooks when experimental.webhooks is enabled (no diff)", () => { - const toml = `${BASE_DISABLED}[experimental.webhooks]\nenabled = true\n`; - const { layer, apiMock, out } = setupService({ - toml, - yes: true, - v1: { enableDatabaseWebhook: () => Effect.succeed({}) }, - }); - return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain("Enabling webhooks for project:"); - expect(methodsOf(apiMock)).toContain("enableDatabaseWebhook"); - }).pipe(Effect.provide(layer)); - }); - it.effect("a webhook enable failure fails the push", () => { const toml = `${BASE_DISABLED}[experimental.webhooks]\nenabled = true\n`; const { layer } = setupService({ @@ -3016,7 +3108,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { // `normalizeApiName` folds an empty `name` into `undefined` before it reaches the target // object — the same degradation every other scenario in this file relies on via // `PUSH_TEST_PROJECT`'s default empty name; this test pins it explicitly. - const { layer, out } = setup({ + const { layer, out, api } = setup({ toml: BRANCH_PUSH_TOML, yes: true, project: { status: 200, body: { ...PUSH_TEST_PROJECT, name: "" } }, @@ -3025,6 +3117,8 @@ describe("config push branch/project target detection (CLI-2168)", () => { yield* configPush({ projectRef: Option.none() }); expect(out.stderrText).toContain(`Pushing config to project: ${REF}\n`); expect(out.stderrText).not.toContain("project: ("); + + assertPermissionDrift({ command: "config push", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }, ); @@ -3043,6 +3137,8 @@ describe("config push branch/project target detection (CLI-2168)", () => { expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, ); + + assertPermissionDrift({ command: "config push", requests: api.requests }); }).pipe(Effect.provide(layer)); }, ); @@ -3069,6 +3165,8 @@ describe("config push branch/project target detection (CLI-2168)", () => { expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, ); + + assertPermissionDrift({ command: "config push", requests: api.requests }); }).pipe(Effect.provide(layer)); }, ); @@ -3088,6 +3186,8 @@ describe("config push branch/project target detection (CLI-2168)", () => { expect(out.stderrText).toContain(`Pushing config to branch: ${PROBE_REF}\n`); expect(out.stderrText).not.toContain("Parent project:"); expect(api.requests.some((r) => r.url.includes("/branches"))).toBe(false); + + assertPermissionDrift({ command: "config push", requests: api.requests }); }).pipe(Effect.provide(layer)); }, ); @@ -3270,6 +3370,8 @@ describe("config push branch/project target detection (CLI-2168)", () => { expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, ); + + assertPermissionDrift({ command: "config push", requests: api.requests }); }).pipe(Effect.provide(layer)); }, ); @@ -3324,6 +3426,8 @@ describe("config push branch/project target detection (CLI-2168)", () => { expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, ); + + assertPermissionDrift({ command: "config push", requests: api.requests }); }).pipe(Effect.provide(layer)); }, ); @@ -3376,6 +3480,12 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () (r) => r.method === "GET" && new URL(r.url).pathname === `/v1/projects/${BRANCH_REF}`, ), ).toBe(false); + + assertPermissionDrift({ + command: "config push", + requests: api.requests, + activeFlags: { "project-ref": true }, + }); }).pipe(Effect.provide(layer)); }, ); @@ -3477,6 +3587,12 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () const branchRequests = api.requests.filter((r) => r.url.includes("/branches")); expect(branchRequests).toHaveLength(1); expect(branchRequests[0]?.url).toContain(`/v1/branches/${BRANCH_UUID}`); + + assertPermissionDrift({ + command: "config push", + requests: api.requests, + activeFlags: { "project-ref": true }, + }); }).pipe(Effect.provide(layer)); }, ); diff --git a/apps/cli/tests/helpers/permission-pending-commands.ts b/apps/cli/tests/helpers/permission-pending-commands.ts index 1758a6972d..967e64c87b 100644 --- a/apps/cli/tests/helpers/permission-pending-commands.ts +++ b/apps/cli/tests/helpers/permission-pending-commands.ts @@ -14,10 +14,6 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "compute push", "compute status", - "config diff", - "config pull", - "config push", - "db branch create", "db branch delete", "db branch list", From 425a637e98b27ecc31cbb7ed21a5079e5bbcaba5 Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Thu, 15 Oct 2026 07:38:23 +0000 Subject: [PATCH 09/26] chore(cli): map project data command permissions (#7169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Record the Management API operations behind 20 project-data commands, so scoped access tokens can be built from what each command actually calls: `sso add|info|list|remove|show|update`, `domains activate|create|delete|get|reverify`, `secrets set|unset`, `snippets download`, `notebooks pull|push` and `storage cp|ls|mv|rm`. Each command declares its own operations and composes the existing `projectRefPicker`, `linkedProjectCache`, `upgradeSuggestion` (sso commands that call the upgrade gate) and `storageCredentials` blocks. No user-facing behaviour changes. New shared pieces: - `notebooksList`, `notebookRead` and `notebookCreate` blocks in `notebooks.shared.ts`, for the calls both notebooks commands make; each command adds its own context. - `STORAGE_PERMISSIONS` in `storage.flags.ts`. Storage routes on the parsed `--local` value, so its credentials and cache entries use `{ flag: "local", is: false }` rather than a database target; `--local=false` targets the linked project. - A `recordManagementApi` option in the storage test setup and a `realLayers` option in the notebooks test setup, so those tests run the real picker, cache and credentials lookup and check the recorded requests against the declarations. Conditions: `secrets unset` lists secrets only when no names are given, and the notebooks entries carry contexts for the id argument, the reconcile answers and which files exist. `exact` drift checks skip those, so they are asserted by request sequence. Things worth knowing: - `sso info` makes no call of its own, but needs a token and fills the project cache; its doc said otherwise. - `snippets download` resolves a project ref it never sends, so it fails unlinked even though the snippet call needs no ref. - `storage cp` resolves credentials before its source/destination checks, and `rm` before its confirmation. - The `--linked`/`--local` mutual-exclusion error isn't modelled, so storage over-declares when both are set to false. - The experimental gate on storage isn't modelled; declarations describe a run with the gate open. - Dropping `linkedProjectCache` from `sso add|list|remove|update` fails no test, because `upgradeSuggestion` also declares the project lookup. - The `SIDE_EFFECTS.md` files for these commands had the wrong linked-project cache path and conditions, and were missing the picker and cache-fill routes; corrected in the last commit. No `OPERATIONS_WITHOUT_FGA` changes. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 --- apps/cli/src/commands/domains/SIDE_EFFECTS.md | 47 +++---- .../domains/activate/activate.command.ts | 14 +++ .../activate/activate.integration.test.ts | 72 ++++++++++- .../commands/domains/create/create.command.ts | 14 +++ .../domains/create/create.integration.test.ts | 74 ++++++++++- .../commands/domains/delete/delete.command.ts | 14 +++ .../domains/delete/delete.integration.test.ts | 68 +++++++++- .../src/commands/domains/get/get.command.ts | 14 +++ .../domains/get/get.integration.test.ts | 72 ++++++++++- .../domains/reverify/reverify.command.ts | 14 +++ .../reverify/reverify.integration.test.ts | 69 ++++++++++- .../commands/notebooks/notebooks.shared.ts | 16 +++ .../commands/notebooks/pull/SIDE_EFFECTS.md | 52 ++++---- .../commands/notebooks/pull/pull.command.ts | 27 +++- .../notebooks/pull/pull.integration.test.ts | 63 +++++++++- .../commands/notebooks/push/SIDE_EFFECTS.md | 54 ++++---- .../commands/notebooks/push/push.command.ts | 38 +++++- .../notebooks/push/push.integration.test.ts | 87 +++++++++++++ .../src/commands/secrets/set/SIDE_EFFECTS.md | 41 +++--- .../src/commands/secrets/set/set.command.ts | 14 +++ .../secrets/set/set.integration.test.ts | 77 +++++++++++- .../commands/secrets/unset/SIDE_EFFECTS.md | 36 +++--- .../commands/secrets/unset/unset.command.ts | 20 +++ .../secrets/unset/unset.integration.test.ts | 73 ++++++++++- .../snippets/download/SIDE_EFFECTS.md | 24 ++-- .../snippets/download/download.command.ts | 11 ++ .../download/download.integration.test.ts | 66 +++++++++- apps/cli/src/commands/sso/add/SIDE_EFFECTS.md | 25 ++-- apps/cli/src/commands/sso/add/add.command.ts | 24 ++++ .../commands/sso/add/add.integration.test.ts | 82 +++++++++++- .../cli/src/commands/sso/info/SIDE_EFFECTS.md | 37 ++++-- .../cli/src/commands/sso/info/info.command.ts | 5 + .../sso/info/info.integration.test.ts | 55 +++++++- .../cli/src/commands/sso/list/SIDE_EFFECTS.md | 21 ++-- .../cli/src/commands/sso/list/list.command.ts | 13 ++ .../sso/list/list.integration.test.ts | 93 +++++++++++++- .../src/commands/sso/remove/SIDE_EFFECTS.md | 21 ++-- .../src/commands/sso/remove/remove.command.ts | 13 ++ .../sso/remove/remove.integration.test.ts | 62 +++++++++- .../cli/src/commands/sso/show/SIDE_EFFECTS.md | 17 +-- .../cli/src/commands/sso/show/show.command.ts | 14 +++ .../sso/show/show.integration.test.ts | 70 ++++++++++- .../src/commands/sso/update/SIDE_EFFECTS.md | 33 ++--- .../src/commands/sso/update/update.command.ts | 28 +++++ .../sso/update/update.integration.test.ts | 82 +++++++++++- .../src/commands/storage/cp/SIDE_EFFECTS.md | 54 ++++---- .../cli/src/commands/storage/cp/cp.command.ts | 9 ++ .../storage/cp/cp.integration.test.ts | 43 ++++++- .../src/commands/storage/ls/SIDE_EFFECTS.md | 36 +++--- .../cli/src/commands/storage/ls/ls.command.ts | 3 + .../storage/ls/ls.integration.test.ts | 117 ++++++++++++++++++ .../src/commands/storage/mv/SIDE_EFFECTS.md | 42 ++++--- .../cli/src/commands/storage/mv/mv.command.ts | 3 + .../storage/mv/mv.integration.test.ts | 43 ++++++- .../src/commands/storage/rm/SIDE_EFFECTS.md | 46 ++++--- .../cli/src/commands/storage/rm/rm.command.ts | 3 + .../storage/rm/rm.integration.test.ts | 42 +++++++ .../cli/src/commands/storage/storage.flags.ts | 9 ++ apps/cli/tests/helpers/notebooks.ts | 49 +++++++- .../helpers/permission-pending-commands.ts | 26 ---- apps/cli/tests/helpers/storage.ts | 56 +++++++-- 61 files changed, 2113 insertions(+), 334 deletions(-) diff --git a/apps/cli/src/commands/domains/SIDE_EFFECTS.md b/apps/cli/src/commands/domains/SIDE_EFFECTS.md index 9c2e7404f9..bfe98b9b81 100644 --- a/apps/cli/src/commands/domains/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/domains/SIDE_EFFECTS.md @@ -6,36 +6,39 @@ Cloudflare DNS-over-HTTPS CNAME pre-check. ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ----------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | +| `/supabase/.temp/linked-project.json` | JSON | existence check only — `linkedProjectCache` skips its fill when the file exists | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | -------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | always (PersistentPostRun), after the ref resolves | -| `~/.supabase/telemetry.json` | JSON | always (PersistentPostRun), success or failure | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | always (PersistentPostRun), success or failure | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ----------------------------------------------- | ------------ | --------------------------- | -------------------------------------------------------------------------------------------- | -| `GET` | `https://1.1.1.1/dns-query?name=&type=5` | none | none | `{Answer: [{type, data}]}` — first CNAME answer (`create` only, before the POST) | -| `POST` | `/v1/projects/{ref}/custom-hostname/initialize` | Bearer token | `{custom_hostname: string}` | 201 → `{status, custom_hostname, data: {result: {ssl, custom_origin_server, …}}}` (`create`) | -| `GET` | `/v1/projects/{ref}/custom-hostname` | Bearer token | none | 200 → same response shape (`get`) | -| `POST` | `/v1/projects/{ref}/custom-hostname/reverify` | Bearer token | none | 201 → same response shape (`reverify`) | -| `POST` | `/v1/projects/{ref}/custom-hostname/activate` | Bearer token | none | 201 → same response shape (`activate`) | -| `DELETE` | `/v1/projects/{ref}/custom-hostname` | Bearer token | none | 200 → empty/void body (`delete`) | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ----------------------------------------------- | ------------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `https://1.1.1.1/dns-query?name=&type=5` | none | none | `{Answer: [{type, data}]}` — first CNAME answer (`create` only, before the POST) | +| `POST` | `/v1/projects/{ref}/custom-hostname/initialize` | Bearer token | `{custom_hostname: string}` | 201 → `{status, custom_hostname, data: {result: {ssl, custom_origin_server, …}}}` (`create`) | +| `GET` | `/v1/projects/{ref}/custom-hostname` | Bearer token | none | 200 → same response shape (`get`) | +| `POST` | `/v1/projects/{ref}/custom-hostname/reverify` | Bearer token | none | 201 → same response shape (`reverify`) | +| `POST` | `/v1/projects/{ref}/custom-hostname/activate` | Bearer token | none | 201 → same response shape (`activate`) | +| `DELETE` | `/v1/projects/{ref}/custom-hostname` | Bearer token | none | 200 → empty/void body (`delete`) | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | -------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to linked-project file) | -| `SUPABASE_PROFILE` | built-in profile name or path to a YAML profile | no (defaults to `supabase`; sets API URL + project host) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the picker on a TTY) | +| `SUPABASE_PROFILE` | built-in profile name or path to a YAML profile | no (defaults to `supabase`; sets API URL + project host) | ## Exit Codes @@ -91,7 +94,7 @@ suppressed on stderr. `delete` ignores `-o`. - `--custom-hostname` is required for `create`. - `create` validates the CNAME via Cloudflare DNS-over-HTTPS (`https://1.1.1.1`, 10s timeout) before initializing; on failure it short-circuits before any POST. -- All subcommands resolve the ref via `--project-ref` → `SUPABASE_PROJECT_ID` env → linked-project file. +- All subcommands resolve the ref via `--project-ref` → `SUPABASE_PROJECT_ID` env → `supabase/.temp/project-ref` → interactive project picker (only when stdin is a TTY and output is interactive). - The project-ref fallback env var is `SUPABASE_PROJECT_ID`. - `--include-raw-output` is deprecated: passing it with any value (including `--include-raw-output=false`) prints `Flag --include-raw-output is deprecated and will be removed in v3, use -o json instead.` to stderr before the API call, in every output mode, on all five subcommands (including `delete`). stdout is unaffected. - **Documented divergences from the old Go CLI (intentional):** diff --git a/apps/cli/src/commands/domains/activate/activate.command.ts b/apps/cli/src/commands/domains/activate/activate.command.ts index 9a83478686..e5df45dfb6 100644 --- a/apps/cli/src/commands/domains/activate/activate.command.ts +++ b/apps/cli/src/commands/domains/activate/activate.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -30,6 +34,16 @@ export const domainsActivateCommand = Command.make("activate", config).pipe( description: "Activate the custom hostname for a project", }, ]), + withPermissions( + compose( + { + operations: [{ operationId: "v1-activate-custom-hostname", kind: "required" }], + noApiEffectFlags: ["include-raw-output"], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => domainsActivate(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/domains/activate/activate.integration.test.ts b/apps/cli/src/commands/domains/activate/activate.integration.test.ts index f352793246..b5818396e8 100644 --- a/apps/cli/src/commands/domains/activate/activate.integration.test.ts +++ b/apps/cli/src/commands/domains/activate/activate.integration.test.ts @@ -1,16 +1,23 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option, Schema } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { + VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { domainsActivate } from "./activate.handler.ts"; const HOSTNAME_RESPONSE: typeof V1GetHostnameConfigOutput.Type = { @@ -43,6 +50,8 @@ interface SetupOpts { const tempRoot = useTempWorkdir("supabase-domains-activate-int-"); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); const analytics = mockAnalytics(); @@ -87,6 +96,7 @@ describe("domains activate integration", () => { const exit = yield* Effect.exit(domainsActivate(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); expect(api.requests).toHaveLength(1); + assertPermissionDrift({ command: "domains activate", requests: api.requests }); expect(out.stderrText).toContain("Upgrade your plan:"); expect(out.stderrText).toContain("/org/env-org/billing"); expect(analytics.captured).toEqual([ @@ -109,6 +119,7 @@ describe("domains activate integration", () => { expect(api.requests[0]?.url).toContain("/custom-hostname/activate"); expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cached).toBe(true); + assertPermissionDrift({ command: "domains activate", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -196,4 +207,61 @@ describe("domains activate integration", () => { expect(out.progressEvents).toHaveLength(0); }).pipe(Effect.provide(layer)); }); + + it.effect( + "picks the project, activates the hostname and fills the linked-project cache through the real layers", + () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + const route = `${request.method} ${request.url}`; + if (route === "GET https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (route === `GET https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if ( + route === + `POST https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname/activate` + ) { + return Effect.succeed(jsonResponse(request, 201, HOSTNAME_RESPONSE)); + } + return Effect.succeed(jsonResponse(request, 404, { message: "unexpected request" })); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* domainsActivate(baseFlags); + expect(out.stderrText).toContain( + "Custom hostname setup completed. Project is now accessible at shop.acme.dev.", + ); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `POST https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname/activate`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "domains activate", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/commands/domains/create/create.command.ts b/apps/cli/src/commands/domains/create/create.command.ts index f8f7737ed6..08fd97a3fa 100644 --- a/apps/cli/src/commands/domains/create/create.command.ts +++ b/apps/cli/src/commands/domains/create/create.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -34,6 +38,16 @@ export const domainsCreateCommand = Command.make("create", config).pipe( description: "Create a custom hostname for a project", }, ]), + withPermissions( + compose( + { + operations: [{ operationId: "v1-update-hostname-config", kind: "required" }], + noApiEffectFlags: ["custom-hostname", "include-raw-output"], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => domainsCreate(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/domains/create/create.integration.test.ts b/apps/cli/src/commands/domains/create/create.integration.test.ts index 6ccb0b2d23..c764df2969 100644 --- a/apps/cli/src/commands/domains/create/create.integration.test.ts +++ b/apps/cli/src/commands/domains/create/create.integration.test.ts @@ -1,9 +1,9 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, Fiber, Option, Schema } from "effect"; +import { Cause, Deferred, Effect, Exit, FileSystem, Fiber, Option, Schema } from "effect"; import { TestClock } from "effect/testing"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, @@ -16,6 +16,11 @@ import { useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; import { DomainsCnameError } from "../domains.errors.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { domainsCreate } from "./create.handler.ts"; const CUSTOM_HOSTNAME = "shop.acme.dev"; @@ -53,6 +58,8 @@ interface SetupOpts { const tempRoot = useTempWorkdir("supabase-domains-create-int-"); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + function setup(opts: SetupOpts = {}) { const cname = opts.cname ?? "ok"; const out = mockOutput({ format: opts.format ?? "text" }); @@ -122,6 +129,7 @@ describe("domains create integration", () => { expect(postedToInitialize(api)).toBe(true); expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cached).toBe(true); + assertPermissionDrift({ command: "domains create", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -143,6 +151,7 @@ describe("domains create integration", () => { expect(Exit.isFailure(exit)).toBe(true); expect(api.requests).toHaveLength(2); expect(postedToInitialize(api)).toBe(true); + assertPermissionDrift({ command: "domains create", requests: api.requests }); expect(out.stderrText).toContain("Upgrade your plan:"); expect(out.stderrText).toContain("/org/env-org/billing"); expect(analytics.captured).toEqual([ @@ -339,4 +348,65 @@ describe("domains create integration", () => { expect(out.progressEvents).toHaveLength(0); }).pipe(Effect.provide(layer)); }); + + it.effect( + "picks the project, creates the hostname and fills the linked-project cache through the real layers", + () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + const route = `${request.method} ${request.url}`; + if (route === "GET https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (route === `GET https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if (request.url.includes("1.1.1.1")) { + return Effect.succeed( + jsonResponse(request, 200, { Answer: [{ type: 5, data: EXPECTED_CNAME }] }), + ); + } + if ( + route === + `POST https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname/initialize` + ) { + return Effect.succeed(jsonResponse(request, 201, HOSTNAME_RESPONSE)); + } + return Effect.succeed(jsonResponse(request, 404, { message: "unexpected request" })); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* domainsCreate(flags()); + expect(out.stderrText).toContain("Custom hostname configuration complete"); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + "GET https://1.1.1.1/dns-query?name=shop.acme.dev&type=5", + `POST https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname/initialize`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "domains create", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/commands/domains/delete/delete.command.ts b/apps/cli/src/commands/domains/delete/delete.command.ts index c45cecd071..8e5e6ca90e 100644 --- a/apps/cli/src/commands/domains/delete/delete.command.ts +++ b/apps/cli/src/commands/domains/delete/delete.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -28,6 +32,16 @@ export const domainsDeleteCommand = Command.make("delete", config).pipe( description: "Delete the custom hostname config for a project", }, ]), + withPermissions( + compose( + { + operations: [{ operationId: "v1-Delete hostname config", kind: "required" }], + noApiEffectFlags: ["include-raw-output"], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => domainsDelete(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/domains/delete/delete.integration.test.ts b/apps/cli/src/commands/domains/delete/delete.integration.test.ts index f7e62c7c80..04613afcc0 100644 --- a/apps/cli/src/commands/domains/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/domains/delete/delete.integration.test.ts @@ -1,15 +1,22 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { + VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { domainsDelete } from "./delete.handler.ts"; type GoOutput = "env" | "pretty" | "json" | "toml" | "yaml"; @@ -23,6 +30,8 @@ interface SetupOpts { const tempRoot = useTempWorkdir("supabase-domains-delete-int-"); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); const api = mockCommandPlatformApi({ @@ -55,6 +64,7 @@ describe("domains delete integration", () => { expect(api.requests[0]?.method).toBe("DELETE"); expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cached).toBe(true); + assertPermissionDrift({ command: "domains delete", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -143,4 +153,58 @@ describe("domains delete integration", () => { expect(out.progressEvents).toHaveLength(0); }).pipe(Effect.provide(layer)); }); + + it.effect( + "picks the project, deletes the hostname config and fills the linked-project cache through the real layers", + () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + const route = `${request.method} ${request.url}`; + if (route === "GET https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (route === `GET https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if ( + route === `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname` + ) { + return Effect.succeed(jsonResponse(request, 200, {})); + } + return Effect.succeed(jsonResponse(request, 404, { message: "unexpected request" })); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* domainsDelete(baseFlags); + expect(out.stderrText).toBe("Deleted custom hostname config successfully.\n"); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "domains delete", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/commands/domains/get/get.command.ts b/apps/cli/src/commands/domains/get/get.command.ts index c16551ad4f..1c3880c715 100644 --- a/apps/cli/src/commands/domains/get/get.command.ts +++ b/apps/cli/src/commands/domains/get/get.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -30,6 +34,16 @@ export const domainsGetCommand = Command.make("get", config).pipe( description: "Get the custom hostname config for a project", }, ]), + withPermissions( + compose( + { + operations: [{ operationId: "v1-get-hostname-config", kind: "required" }], + noApiEffectFlags: ["include-raw-output"], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => domainsGet(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/domains/get/get.integration.test.ts b/apps/cli/src/commands/domains/get/get.integration.test.ts index 86d38e6acd..4d7552ebbe 100644 --- a/apps/cli/src/commands/domains/get/get.integration.test.ts +++ b/apps/cli/src/commands/domains/get/get.integration.test.ts @@ -1,16 +1,23 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option, Schema } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { + VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { domainsGet } from "./get.handler.ts"; const HOSTNAME_RESPONSE: typeof V1GetHostnameConfigOutput.Type = { @@ -56,6 +63,8 @@ const GoJsonEnvelope = Schema.Record(Schema.String, Schema.Unknown); const tempRoot = useTempWorkdir("supabase-domains-get-int-"); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); const analytics = mockAnalytics(); @@ -84,13 +93,17 @@ const DeprecationJsonObject = Schema.fromJsonString(Schema.Record(Schema.String, describe("domains get integration", () => { it.effect("prints the hostname status to stderr in text mode", () => { - const { layer, out, telemetry, linkedProjectCache } = setup(); + const { layer, out, api, telemetry, linkedProjectCache } = setup(); return Effect.gen(function* () { yield* domainsGet(baseFlags); expect(out.stderrText).toContain("Custom hostname configuration not started."); expect(out.stdoutText).toBe(""); expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cached).toBe(true); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname`, + ]); + assertPermissionDrift({ command: "domains get", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -419,6 +432,7 @@ describe("domains get integration", () => { const exit = yield* Effect.exit(domainsGet(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); expect(api.requests).toHaveLength(1); + assertPermissionDrift({ command: "domains get", requests: api.requests }); expect(out.stderrText).toContain("Upgrade your plan:"); expect(out.stderrText).toContain("/org/env-org/billing"); expect(analytics.captured).toEqual([ @@ -465,4 +479,56 @@ describe("domains get integration", () => { } }).pipe(Effect.provide(layer)); }); + + it.effect( + "picks the project, fetches the hostname config and fills the linked-project cache through the real layers", + () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + const route = `${request.method} ${request.url}`; + if (route === "GET https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (route === `GET https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if (route === `GET https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname`) { + return Effect.succeed(jsonResponse(request, 200, HOSTNAME_RESPONSE)); + } + return Effect.succeed(jsonResponse(request, 404, { message: "unexpected request" })); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* domainsGet(baseFlags); + expect(out.stderrText).toContain("Custom hostname configuration not started."); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "domains get", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/commands/domains/reverify/reverify.command.ts b/apps/cli/src/commands/domains/reverify/reverify.command.ts index 8f686eaa02..8ff4d02fee 100644 --- a/apps/cli/src/commands/domains/reverify/reverify.command.ts +++ b/apps/cli/src/commands/domains/reverify/reverify.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -28,6 +32,16 @@ export const domainsReverifyCommand = Command.make("reverify", config).pipe( description: "Re-verify the custom hostname for a project", }, ]), + withPermissions( + compose( + { + operations: [{ operationId: "v1-verify-dns-config", kind: "required" }], + noApiEffectFlags: ["include-raw-output"], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => domainsReverify(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts b/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts index d5e24c50b0..72cc6fb331 100644 --- a/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts +++ b/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts @@ -1,16 +1,23 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option, Schema } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { + VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { domainsReverify } from "./reverify.handler.ts"; const HOSTNAME_RESPONSE: typeof V1GetHostnameConfigOutput.Type = { @@ -43,6 +50,8 @@ interface SetupOpts { const tempRoot = useTempWorkdir("supabase-domains-reverify-int-"); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); const analytics = mockAnalytics(); @@ -107,6 +116,7 @@ describe("domains reverify integration", () => { expect(api.requests[0]?.url).toContain("/custom-hostname/reverify"); expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cached).toBe(true); + assertPermissionDrift({ command: "domains reverify", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -195,4 +205,59 @@ describe("domains reverify integration", () => { expect(out.progressEvents).toHaveLength(0); }).pipe(Effect.provide(layer)); }); + + it.effect( + "picks the project, reverifies the hostname and fills the linked-project cache through the real layers", + () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + const route = `${request.method} ${request.url}`; + if (route === "GET https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (route === `GET https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if ( + route === + `POST https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname/reverify` + ) { + return Effect.succeed(jsonResponse(request, 201, HOSTNAME_RESPONSE)); + } + return Effect.succeed(jsonResponse(request, 404, { message: "unexpected request" })); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* domainsReverify(baseFlags); + expect(out.stderrText).toContain("being initialized"); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `POST https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname/reverify`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "domains reverify", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/commands/notebooks/notebooks.shared.ts b/apps/cli/src/commands/notebooks/notebooks.shared.ts index 210ff45e71..49228c549f 100644 --- a/apps/cli/src/commands/notebooks/notebooks.shared.ts +++ b/apps/cli/src/commands/notebooks/notebooks.shared.ts @@ -1,6 +1,7 @@ import type { ApiClient } from "@supabase/api/effect"; import { V2GetNotebookInput, V2UpdateNotebookInput } from "@supabase/api/effect"; import { Effect, Exit, FileSystem, Option, Path, Schema, Stream } from "effect"; +import { block } from "../../command-internal/command-permissions/model.ts"; import { Output } from "../../shared/output/output.service.ts"; import { notebooksMachineOutputRequested } from "./notebooks.output.ts"; import { sanitizeInlineName, mapHttpError } from "../../command-internal/http-errors.ts"; @@ -31,6 +32,21 @@ import { * refused rather than guessed at (`NotebookNameConflictError`). */ +/** `listRemoteNotebooks`: every page of the project's notebooks. */ +export const notebooksList = block("notebooks-list", [ + { operationId: "v2-list-notebooks", kind: "required" }, +]); + +/** `downloadNotebook` / `downloadNotebookById`: one notebook's content. */ +export const notebookRead = block("notebook-read", [ + { operationId: "v2-get-notebook", kind: "required" }, +]); + +/** `uploadNotebook` for a name no project notebook carries. */ +export const notebookCreate = block("notebook-create", [ + { operationId: "v2-create-notebook", kind: "required" }, +]); + /** `supabase/notebooks/`, alongside `supabase/functions/` and `supabase/workers/`. */ export const notebooksDir = Effect.fnUntraced(function* (workdir: string) { const path = yield* Path.Path; diff --git a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md index 7c6a1b5a7d..7b73d07277 100644 --- a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md @@ -7,21 +7,24 @@ the timestamps, the `owner` / `updated_by` identities, and `content.schema_versi ## Files Read -| Path | Format | When | -| ------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------- | -| `/supabase/notebooks/` | dir | when no notebook id is given — to preserve existing notebooks and find local-only notebooks | -| `/supabase/notebooks/.json` | JSON | only for a local-only notebook the user chose to create in the project | -| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | -| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | +| Path | Format | When | +| ---------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `/supabase/notebooks/` | dir | when no notebook id is given — to preserve existing notebooks and find local-only notebooks | +| `/supabase/notebooks/.json` | JSON | only for a local-only notebook the user chose to create in the project | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/linked-project.json` | JSON | existence check before the cache fill, once the project ref resolves | +| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | +| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | ## Files Written -| Path | Format | When | -| ---------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------- | -| `/supabase/notebooks/` | dir | created if absent, before the first notebook is written | -| `/supabase/notebooks/.notebook-/` | JSON | scoped temporary file; linked for creation or renamed for explicit replacement, then cleaned | -| `/supabase/notebooks/.json` | JSON | only when absent during a broad pull, or replaced when its notebook id was explicitly given | -| `/telemetry.json` | JSON | whenever the handler runs — flushed on success and on failure | +| Path | Format | When | +| ---------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/notebooks/` | dir | created if absent, before the first notebook is written | +| `/supabase/notebooks/.notebook-/` | JSON | scoped temporary file; linked for creation or renamed for explicit replacement, then cleaned | +| `/supabase/notebooks/.json` | JSON | only when absent during a broad pull, or replaced when its notebook id was explicitly given | +| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | +| `/telemetry.json` | JSON | whenever the handler runs — flushed on success and on failure | Files are **removed** only when the user picks the delete answer at the reconciliation prompt: a local notebook naming no project notebook is deleted @@ -54,11 +57,13 @@ nothing else is reconciled. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------- | ------ | ------------------------------- | --------------------------------------------------------------------- | -| GET | `/v2/projects/{ref}/notebooks` | Bearer | — | `data[].id`, `data[].attributes.name`, `links.next` — broad pull only | -| GET | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | `data.{id,attributes.{name,description,favorite,content}}` | -| POST | `/v2/projects/{ref}/notebooks` | Bearer | `data.attributes` from the file | — (only for the create answer) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------- | ------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GET | `/v2/projects/{ref}/notebooks` | Bearer | — | `data[].id`, `data[].attributes.name`, `links.next` — broad pull only | +| GET | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | `data.{id,attributes.{name,description,favorite,content}}` | +| POST | `/v2/projects/{ref}/notebooks` | Bearer | `data.attributes` from the file | — (only for the create answer) | +| GET | `/v1/projects` | Bearer | — | `id`, `name`, `organization_slug`, `region` — project picker; only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | +| GET | `/v1/projects/{ref}` | Bearer | — | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | The list route is walked by following `links.next` until it is null, rather than by counting rows: a short page cannot be told from an exact multiple of the page @@ -81,12 +86,13 @@ size. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------- | ------------------------------------------------------ | -| `SUPABASE_ACCESS_TOKEN` | Management API bearer token | no (falls back to the stored login) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | -| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------- | ------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | Management API bearer token | no (falls back to the stored login) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the picker on a TTY) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/notebooks/pull/pull.command.ts b/apps/cli/src/commands/notebooks/pull/pull.command.ts index 5cac62d7de..e0b34a738c 100644 --- a/apps/cli/src/commands/notebooks/pull/pull.command.ts +++ b/apps/cli/src/commands/notebooks/pull/pull.command.ts @@ -1,9 +1,18 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withContext } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; -import { notebooksProjectRefSafeFlags } from "../notebooks.shared.ts"; +import { + notebookCreate, + notebookRead, + notebooksList, + notebooksProjectRefSafeFlags, +} from "../notebooks.shared.ts"; import { notebooksPull } from "./pull.handler.ts"; const config = { @@ -44,6 +53,22 @@ export const notebooksPullCommand = Command.make("pull", config).pipe( description: "Replace the local copy of one notebook by id", }, ]), + withPermissions( + compose( + { operations: [] }, + projectRefPicker, + withContext(notebooksList, "no notebook id argument"), + withContext( + notebookRead, + "a notebook id argument, or no id argument and a project notebook with no file in supabase/notebooks", + ), + withContext( + notebookCreate, + "no notebook id argument, local notebooks the project lacks, stdout a TTY with text output, and the create answer", + ), + linkedProjectCache, + ), + ), Command.withHandler(notebooksPullHandler), Command.provide(managementApiRuntimeLayer(["notebooks", "pull"])), ); diff --git a/apps/cli/src/commands/notebooks/pull/pull.integration.test.ts b/apps/cli/src/commands/notebooks/pull/pull.integration.test.ts index 649925cdda..7a7e4a11e9 100644 --- a/apps/cli/src/commands/notebooks/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/notebooks/pull/pull.integration.test.ts @@ -1,14 +1,21 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Option, Schema } from "effect"; +import { Effect, FileSystem, Option, Schema } from "effect"; import { useTempWorkdir } from "../../../../tests/helpers/command-mocks.ts"; import { + NOTEBOOKS_PROJECT_REF, notebookListPage, notebookResource, notebooksProject, notebooksRoute, setupNotebooks, } from "../../../../tests/helpers/notebooks.ts"; +import { + assertBlockDrift, + assertPermissionDrift, +} from "../../../../tests/helpers/permission-drift.ts"; +import { projectResponse } from "../../../../tests/helpers/permission-real-layers.ts"; import { NotebookIdError, NotebookNameConflictError } from "../notebooks.errors.ts"; +import { notebooksList } from "../notebooks.shared.ts"; import { notebooksPullHandler as notebooksPull } from "./pull.command.ts"; import type { NotebooksPullFlags } from "./pull.command.ts"; @@ -72,6 +79,7 @@ describe("notebooks pull", () => { ]); expect(out.stdoutText).toContain("Pulled 1 notebook(s)"); expect(out.stdoutText).toContain("Kept 1 existing local notebook(s) unchanged."); + assertPermissionDrift({ command: "notebooks pull", requests: http.driftRequests }); }).pipe(Effect.provide(layer)); }); @@ -101,6 +109,7 @@ describe("notebooks pull", () => { }); expect(yield* repo.exists("leftover")).toBe(true); expect(http.routeKeys).toEqual([`GET ${notebooksRoute(`/${SALES_ID}`)}`]); + assertPermissionDrift({ command: "notebooks pull", requests: http.driftRequests }); }).pipe(Effect.provide(layer)); }); @@ -171,6 +180,8 @@ describe("notebooks pull", () => { }, }, }); + expect(http.routeKeys).toEqual([`GET ${notebooksRoute()}`, `POST ${notebooksRoute()}`]); + assertPermissionDrift({ command: "notebooks pull", requests: http.driftRequests }); }).pipe(Effect.provide(layer)); }); @@ -178,7 +189,7 @@ describe("notebooks pull", () => { // divergence and resolves nothing. it.effect("leaves both sides alone when there is nobody to ask", () => { const repo = project(); - const { layer, out } = setupNotebooks({ + const { layer, out, http } = setupNotebooks({ workdir: repo.dir, format: "json", routes: { @@ -191,6 +202,8 @@ describe("notebooks pull", () => { yield* notebooksPull(flags()); expect(yield* repo.exists("gone")).toBe(true); + expect(http.routeKeys).toEqual([`GET ${notebooksRoute()}`]); + assertPermissionDrift({ command: "notebooks pull", requests: http.driftRequests }); expect(out.promptSelectCalls).toHaveLength(0); expect(out.messages).toContainEqual( expect.objectContaining({ @@ -249,6 +262,52 @@ describe("notebooks pull", () => { }).pipe(Effect.provide(layer)); }); + it.effect("picks the project, pulls its notebooks and records the linked project", () => { + const repo = project(); + const { layer, http, out } = setupNotebooks({ + workdir: repo.dir, + realLayers: true, + promptSelectResponses: [NOTEBOOKS_PROJECT_REF], + routes: { + "GET /v1/projects": { status: 200, body: [projectResponse(NOTEBOOKS_PROJECT_REF)] }, + [`GET /v1/projects/${NOTEBOOKS_PROJECT_REF}`]: { + status: 200, + body: projectResponse(NOTEBOOKS_PROJECT_REF), + }, + [`GET ${notebooksRoute()}`]: { + status: 200, + body: notebookListPage({ notebooks: [{ id: SALES_ID, name: "sales-dashboard" }] }), + }, + [`GET ${notebooksRoute(`/${SALES_ID}`)}`]: { + status: 200, + body: { data: notebookResource({ id: SALES_ID, name: "sales-dashboard" }) }, + }, + }, + }); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* notebooksPull(flags()); + + expect(http.routeKeys).toEqual([ + "GET /v1/projects", + `GET ${notebooksRoute()}`, + `GET ${notebooksRoute(`/${SALES_ID}`)}`, + `GET /v1/projects/${NOTEBOOKS_PROJECT_REF}`, + ]); + expect(out.promptSelectCalls).toHaveLength(1); + expect(yield* repo.exists("sales-dashboard")).toBe(true); + expect(out.stdoutText).toContain("Pulled 1 notebook(s)"); + expect(yield* fs.exists(`${repo.dir}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ command: "notebooks pull", requests: http.driftRequests }); + assertBlockDrift(notebooksList, { + requests: http.driftRequests.filter( + (request) => new URL(request.url).pathname === notebooksRoute(), + ), + }); + }).pipe(Effect.provide(layer)); + }); + it.effect("skips a notebook whose name cannot be a file name", () => { const repo = project(); const { layer, out } = setupNotebooks({ diff --git a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md index 8cda1d906e..887fc6168d 100644 --- a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md @@ -8,12 +8,14 @@ notebook, and one with no match creates a new notebook. ## Files Read -| Path | Format | When | -| ------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------- | -| `/supabase/notebooks/` | dir | always — the file names are the notebook names; missing means empty, while any other read failure aborts | -| `/supabase/notebooks/.json` | JSON | one per notebook being pushed; all of them are read and decoded before the first call | -| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | -| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | +| Path | Format | When | +| ---------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `/supabase/notebooks/` | dir | always — the file names are the notebook names; missing means empty, while any other read failure aborts | +| `/supabase/notebooks/.json` | JSON | one per notebook being pushed; all of them are read and decoded before the first call | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/linked-project.json` | JSON | existence check before the cache fill, once the project ref resolves | +| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | +| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | Every file is read and decoded **before** the first write, so a directory holding one unreadable notebook fails without having half-pushed the rest. @@ -25,11 +27,12 @@ kept or deleted remotely. All selected downloads are read before uploads begin. ## Files Written -| Path | Format | When | -| ---------------------------------------------------------- | ------ | --------------------------------------------------------------------------- | -| `/supabase/notebooks/.notebook-/` | JSON | scoped temporary file; atomically linked to `.json`, then cleaned | -| `/supabase/notebooks/.json` | JSON | only for a project-only notebook the user chose to write into the directory | -| `/telemetry.json` | JSON | whenever the handler runs — flushed on success and on failure | +| Path | Format | When | +| ---------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/notebooks/.notebook-/` | JSON | scoped temporary file; atomically linked to `.json`, then cleaned | +| `/supabase/notebooks/.json` | JSON | only for a project-only notebook the user chose to write into the directory | +| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | +| `/telemetry.json` | JSON | whenever the handler runs — flushed on success and on failure | Nothing local is ever removed by `push`. @@ -54,13 +57,15 @@ entry are canonicalized and refused when they resolve outside the project root. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------- | ------ | -------------------------------------- | --------------------------------------------------- | -| GET | `/v2/projects/{ref}/notebooks` | Bearer | — | `data[].id`, `data[].attributes.name`, `links.next` | -| POST | `/v2/projects/{ref}/notebooks` | Bearer | `data.attributes` — name plus the file | — | -| PATCH | `/v2/projects/{ref}/notebooks/{id}` | Bearer | `data.attributes` — name plus the file | — | -| DELETE | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | — (only for the delete answer) | -| GET | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | — (only for the write-locally answer) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------- | ------ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GET | `/v2/projects/{ref}/notebooks` | Bearer | — | `data[].id`, `data[].attributes.name`, `links.next` | +| POST | `/v2/projects/{ref}/notebooks` | Bearer | `data.attributes` — name plus the file | — | +| PATCH | `/v2/projects/{ref}/notebooks/{id}` | Bearer | `data.attributes` — name plus the file | — | +| DELETE | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | — (only for the delete answer) | +| GET | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | — (only for the write-locally answer) | +| GET | `/v1/projects` | Bearer | — | `id`, `name`, `organization_slug`, `region` — project picker; only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | +| GET | `/v1/projects/{ref}` | Bearer | — | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | `content` replaces the whole notebook body, and a cell keeps its identity by echoing back its `id` — which is why a pulled file carries cell ids and a push @@ -85,12 +90,13 @@ never mentions `favorite` keeps whatever the dashboard set. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------- | ------------------------------------------------------ | -| `SUPABASE_ACCESS_TOKEN` | Management API bearer token | no (falls back to the stored login) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | -| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------- | ------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | Management API bearer token | no (falls back to the stored login) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the picker on a TTY) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/notebooks/push/push.command.ts b/apps/cli/src/commands/notebooks/push/push.command.ts index 983d3f48cd..e473a861f5 100644 --- a/apps/cli/src/commands/notebooks/push/push.command.ts +++ b/apps/cli/src/commands/notebooks/push/push.command.ts @@ -1,9 +1,18 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withContext } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; -import { notebooksProjectRefSafeFlags } from "../notebooks.shared.ts"; +import { + notebookCreate, + notebookRead, + notebooksList, + notebooksProjectRefSafeFlags, +} from "../notebooks.shared.ts"; import { notebooksPush } from "./push.handler.ts"; const config = { @@ -42,6 +51,33 @@ export const notebooksPushCommand = Command.make("push", config).pipe( description: "Push a single notebook by name", }, ]), + withPermissions( + compose( + { + operations: [ + { + operationId: "v2-update-notebook", + kind: "required", + context: "a notebook file whose name a project notebook carries", + }, + { + operationId: "v2-delete-notebook", + kind: "required", + context: + "no notebook name argument, project notebooks with no local file, stdout a TTY with text output, and the delete answer", + }, + ], + }, + projectRefPicker, + notebooksList, + withContext(notebookCreate, "a notebook file whose name no project notebook carries"), + withContext( + notebookRead, + "no notebook name argument, project notebooks with no local file, stdout a TTY with text output, and the write-locally answer", + ), + linkedProjectCache, + ), + ), Command.withHandler(notebooksPushHandler), Command.provide(managementApiRuntimeLayer(["notebooks", "push"])), ); diff --git a/apps/cli/src/commands/notebooks/push/push.integration.test.ts b/apps/cli/src/commands/notebooks/push/push.integration.test.ts index 4f39c0bbad..1d883a5a19 100644 --- a/apps/cli/src/commands/notebooks/push/push.integration.test.ts +++ b/apps/cli/src/commands/notebooks/push/push.integration.test.ts @@ -2,17 +2,24 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Option, Path, Schema } from "effect"; import { useTempWorkdir } from "../../../../tests/helpers/command-mocks.ts"; import { + NOTEBOOKS_PROJECT_REF, notebookListPage, notebookResource, notebooksProject, notebooksRoute, setupNotebooks, } from "../../../../tests/helpers/notebooks.ts"; +import { + assertBlockDrift, + assertPermissionDrift, +} from "../../../../tests/helpers/permission-drift.ts"; +import { projectResponse } from "../../../../tests/helpers/permission-real-layers.ts"; import { NotebookFileError, NotebookNameConflictError, NotebookNotFoundError, } from "../notebooks.errors.ts"; +import { notebookCreate, notebookRead, notebooksList } from "../notebooks.shared.ts"; import { notebooksPushHandler as notebooksPush } from "./push.command.ts"; import type { NotebooksPushFlags } from "./push.command.ts"; @@ -82,6 +89,16 @@ describe("notebooks push", () => { }); expect(http.requests.filter((request) => request.method === "POST")).toHaveLength(1); expect(out.stdoutText).toContain("(1 created, 1 updated)"); + expect(http.routeKeys).toEqual([ + `GET ${notebooksRoute()}`, + `POST ${notebooksRoute()}`, + `PATCH ${notebooksRoute(`/${SALES_ID}`)}`, + ]); + assertPermissionDrift({ command: "notebooks push", requests: http.driftRequests }); + assertBlockDrift(notebookCreate, { + requests: http.driftRequests.filter((request) => request.method === "POST"), + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -111,6 +128,10 @@ describe("notebooks push", () => { `GET ${notebooksRoute()}`, `PATCH ${notebooksRoute(`/${SALES_ID}`)}`, ]); + assertPermissionDrift({ + command: "notebooks push", + requests: http.driftRequests, + }); }).pipe(Effect.provide(layer)); }); @@ -176,6 +197,12 @@ describe("notebooks push", () => { expect(http.routeKeys).toContain(`DELETE ${notebooksRoute(`/${ERRORS_ID}`)}`); expect(out.stderrText).toContain(" • stale"); expect(out.stdoutText).toContain("Deleted 1 notebook(s) from the project."); + expect(http.routeKeys).toEqual([ + `GET ${notebooksRoute()}`, + `PATCH ${notebooksRoute(`/${SALES_ID}`)}`, + `DELETE ${notebooksRoute(`/${ERRORS_ID}`)}`, + ]); + assertPermissionDrift({ command: "notebooks push", requests: http.driftRequests }); }).pipe(Effect.provide(layer)); }); @@ -202,6 +229,17 @@ describe("notebooks push", () => { expect(yield* repo.exists("error-rates")).toBe(true); expect(http.routeKeys).not.toContain(`DELETE ${notebooksRoute(`/${ERRORS_ID}`)}`); + expect(http.routeKeys).toEqual([ + `GET ${notebooksRoute()}`, + `GET ${notebooksRoute(`/${ERRORS_ID}`)}`, + ]); + assertPermissionDrift({ command: "notebooks push", requests: http.driftRequests }); + assertBlockDrift(notebookRead, { + requests: http.driftRequests.filter( + (request) => new URL(request.url).pathname === notebooksRoute(`/${ERRORS_ID}`), + ), + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -224,6 +262,7 @@ describe("notebooks push", () => { expect(out.promptSelectCalls).toHaveLength(0); expect(http.routeKeys).toEqual([`GET ${notebooksRoute()}`]); + assertPermissionDrift({ command: "notebooks push", requests: http.driftRequests }); expect(out.messages).toContainEqual( expect.objectContaining({ type: "success", @@ -233,6 +272,54 @@ describe("notebooks push", () => { }).pipe(Effect.provide(layer)); }); + it.effect("picks the project, pushes its notebooks and records the linked project", () => { + const repo = project(); + const { layer, http, out } = setupNotebooks({ + command: "push", + workdir: repo.dir, + realLayers: true, + promptSelectResponses: [NOTEBOOKS_PROJECT_REF], + routes: { + "GET /v1/projects": { status: 200, body: [projectResponse(NOTEBOOKS_PROJECT_REF)] }, + [`GET /v1/projects/${NOTEBOOKS_PROJECT_REF}`]: { + status: 200, + body: projectResponse(NOTEBOOKS_PROJECT_REF), + }, + [`GET ${notebooksRoute()}`]: { + status: 200, + body: notebookListPage({ notebooks: [{ id: SALES_ID, name: "sales-dashboard" }] }), + }, + [`PATCH ${notebooksRoute(`/${SALES_ID}`)}`]: { + status: 200, + body: { data: notebookResource({ id: SALES_ID, name: "sales-dashboard" }) }, + }, + }, + }); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* repo.write("sales-dashboard", SALES_FILE); + yield* notebooksPush(flags()); + + expect(http.routeKeys).toEqual([ + "GET /v1/projects", + `GET ${notebooksRoute()}`, + `PATCH ${notebooksRoute(`/${SALES_ID}`)}`, + `GET /v1/projects/${NOTEBOOKS_PROJECT_REF}`, + ]); + expect(out.promptSelectCalls).toHaveLength(1); + expect(out.stdoutText).toContain("(0 created, 1 updated)"); + expect(yield* fs.exists(`${repo.dir}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ command: "notebooks push", requests: http.driftRequests }); + assertBlockDrift(notebooksList, { + requests: http.driftRequests.filter( + (request) => new URL(request.url).pathname === notebooksRoute(), + ), + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + it.effect("fails instead of treating an unreadable notebooks path as empty", () => { const repo = project(); const { layer, http } = setupNotebooks({ command: "push", workdir: repo.dir }); diff --git a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md index 24801e09a5..6c018771b9 100644 --- a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md @@ -2,31 +2,33 @@ ## Files Read -| Path | Format | When | -| ----------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | -| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | -| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | -| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | -| `/supabase/config.toml` | TOML | always (for `[edge_runtime.secrets]`) — via `@supabase/config`'s `loadCliConfig`; a parse failure is logged to the debug logger and tolerated, not fatal | -| `/.env` | dotenv | always — context for `env(VAR)` interpolation in `[edge_runtime.secrets]` values | -| `/.env.local` | dotenv | always — overrides `.env` for `env(VAR)` interpolation context | -| `` (absolute or CWD-relative) | dotenv | when `--env-file` flag is provided | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | +| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/linked-project.json` | JSON | existence check before the cache fill (in `Effect.ensuring` after the project ref resolves) | +| `/supabase/config.toml` | TOML | always (for `[edge_runtime.secrets]`) — via `@supabase/config`'s `loadCliConfig`; a parse failure is logged to the debug logger and tolerated, not fatal | +| `/.env` | dotenv | always — context for `env(VAR)` interpolation in `[edge_runtime.secrets]` values | +| `/.env.local` | dotenv | always — overrides `.env` for `env(VAR)` interpolation context | +| `` (absolute or CWD-relative) | dotenv | when `--env-file` flag is provided | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the project ref resolves — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------- | ------------ | -------------------------------------- | ------------------------ | -| `POST` | `/v1/projects/{ref}/secrets` | Bearer token | `[{name: string, value: string}, ...]` | none (201 expected) | -| `GET` | `/v1/projects` | Bearer token | none | TTY-prompt fallback only | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------- | ------------ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST` | `/v1/projects/{ref}/secrets` | Bearer token | `[{name: string, value: string}, ...]` | none (201 expected) | +| `GET` | `/v1/projects` | Bearer token | none | project picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | ## Environment Variables @@ -80,6 +82,7 @@ One `result` NDJSON event on success containing `{project_ref, count}`. - Source order for merging entries: `[edge_runtime.secrets]` from `config.toml` (only resolved entries — see below) → `--env-file` (overrides config) → CLI args (overrides env-file). - `SUPABASE_`-prefixed entries are skipped post-merge with a stderr warning. +- Secrets are POSTed in batches of 100 (the API's per-request cap); every batch is validated before the first POST. When no secrets remain after filtering, the command fails with `SecretsNoArgumentsError` and makes no POST (the cache fill still runs). - `[edge_runtime.secrets]` from config.toml is read via `@supabase/config`'s `loadCliConfig` + `resolveCliConfigSubtree`. Resolved secret values arrive wrapped in `Redacted`; unresolved `env(VAR)` literals (env var unset) stay as plain strings and are filtered out at the handler (secrets whose value never resolved past the literal `env(VAR)` form are dropped). - A malformed `config.toml` does **not** abort the command — the error is logged to the debug logger and the command proceeds. `--env-file` and positional `NAME=VALUE` secrets always still apply. What happens to config-declared secrets depends on the failure class: a raw TOML/JSON syntax error drops everything (no `EdgeRuntime.Secrets`), but a schema-type error on an _unrelated_ field (e.g. `analytics.port` being a string) still leaves a valid `[edge_runtime.secrets]` section usable — the handler recovers it by re-decoding just that subtree. Pass `--debug` to see the logged parse error. - Sends `User-Agent: SupabaseCLI/` and Bearer auth. No `X-Supabase-Command` headers. diff --git a/apps/cli/src/commands/secrets/set/set.command.ts b/apps/cli/src/commands/secrets/set/set.command.ts index 2b42e12df1..144f06953d 100644 --- a/apps/cli/src/commands/secrets/set/set.command.ts +++ b/apps/cli/src/commands/secrets/set/set.command.ts @@ -1,6 +1,10 @@ import type * as CliCommand from "effect/cli/Command"; import { Argument, Command, Flag } from "effect/cli"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -36,6 +40,16 @@ export const secretsSetCommand = Command.make("set", config).pipe( description: "Set secrets from a .env file", }, ]), + withPermissions( + compose( + { + operations: [{ operationId: "v1-bulk-create-secrets", kind: "required" }], + noApiEffectFlags: ["env-file"], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => secretsSet(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/secrets/set/set.integration.test.ts b/apps/cli/src/commands/secrets/set/set.integration.test.ts index 270ffb26a3..a4a720c6ef 100644 --- a/apps/cli/src/commands/secrets/set/set.integration.test.ts +++ b/apps/cli/src/commands/secrets/set/set.integration.test.ts @@ -12,14 +12,25 @@ import { Schema, } from "effect"; -import { mockOutput, mockRuntimeInfo, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; +import { + mockOutput, + mockRuntimeInfo, + mockTty, + processEnvLayer, +} from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockCommandPlatformApi, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { DebugLogger } from "../../../command-internal/debug-logger.service.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; import { secretsSet } from "./set.handler.ts"; @@ -115,6 +126,8 @@ const writeConfig = (content: string) => writeSupabaseFile("config.toml", conten const writeSupabaseDotEnv = (content: string) => writeSupabaseFile(".env", content); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const decodePostBody = Schema.decodeUnknownEffect( Schema.Array(Schema.Struct({ name: Schema.String, value: Schema.String })), { onExcessProperty: "error" }, @@ -132,6 +145,7 @@ describe("secrets set integration", () => { expect(api.requests).toHaveLength(1); expect(api.requests[0]?.body).toEqual([{ name: "FOO", value: "bar" }]); expect(out.stdoutText).toBe("Finished supabase secrets set.\n"); + assertPermissionDrift({ command: "secrets set", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -171,6 +185,7 @@ describe("secrets set integration", () => { expect(names.has(`KEY${i}`)).toBe(true); } expect(out.stdoutText).toContain("Finished supabase secrets set."); + assertPermissionDrift({ command: "secrets set", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -189,6 +204,60 @@ describe("secrets set integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("lists projects for the picker and fills the real linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.method === "POST") return Effect.succeed(jsonResponse(request, 201, null)); + return Effect.succeed( + request.url === "https://api.supabase.com/v1/projects" + ? jsonResponse(request, 200, [projectResponse(VALID_REF)]) + : jsonResponse(request, 200, projectResponse(VALID_REF)), + ); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }), + mockRuntimeInfo({ cwd: tempRoot.current }), + processEnvLayer({}), + mockDebugLoggerTracked().layer, + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* secretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `POST https://api.supabase.com/v1/projects/${VALID_REF}/secrets`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(api.requests[1]?.body).toEqual([{ name: "FOO", value: "bar" }]); + expect(out.stdoutText).toBe("Finished supabase secrets set.\n"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "secrets set", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + it.effect( "rejects the whole upload when a later batch has an invalid entry (no partial update)", () => { @@ -229,6 +298,12 @@ describe("secrets set integration", () => { secrets: [], }); expect(api.requests[0]?.body).toEqual([{ name: "FROM_FILE", value: "fromvalue" }]); + assertPermissionDrift({ + command: "secrets set", + activeFlags: { "env-file": true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md index 23a3ad2cbf..7b384636d5 100644 --- a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md @@ -2,28 +2,30 @@ ## Files Read -| Path | Format | When | -| ----------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | -| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | -| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | -| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | -| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | +| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | +| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/linked-project.json` | JSON | existence check before the cache fill (in `Effect.ensuring` after the project ref resolves) | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the project ref resolves — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ---------------------------- | ------------ | --------------- | ------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/secrets` | Bearer token | none | `[{name}]` — empty-args path only, filters `SUPABASE_` prefix | -| `DELETE` | `/v1/projects/{ref}/secrets` | Bearer token | `["NAME", ...]` | none (200 expected) | -| `GET` | `/v1/projects` | Bearer token | none | TTY-prompt fallback only | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ---------------------------- | ------------ | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/secrets` | Bearer token | none | `[{name}]` — empty-args path only, filters `SUPABASE_` prefix | +| `DELETE` | `/v1/projects/{ref}/secrets` | Bearer token | `["NAME", ...]` | none (200 expected) | +| `GET` | `/v1/projects` | Bearer token | none | project picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | ## Environment Variables @@ -76,5 +78,5 @@ One `result` NDJSON event on success containing `{project_ref, count}`. - When called without arguments, fetches the full secret list and unsets all entries that do not have a `SUPABASE_` prefix. - `--yes` bypasses the confirmation prompt with a stderr label echo. -- Non-TTY without `--yes` auto-confirms silently, after a 100ms non-TTY read timeout. +- Non-TTY without `--yes`, in text output, prints the prompt label to stderr and reads one piped line (100ms timeout): `y`/`yes` or an empty line confirms; `n`/`no` or any other answer declines with `SecretsUnsetCancelledError`. With `--output-format json|stream-json` the prompt is skipped and confirms. - Sends `User-Agent: SupabaseCLI/` and Bearer auth. No `X-Supabase-Command` headers. diff --git a/apps/cli/src/commands/secrets/unset/unset.command.ts b/apps/cli/src/commands/secrets/unset/unset.command.ts index 1b7ee38301..5b07cf19fc 100644 --- a/apps/cli/src/commands/secrets/unset/unset.command.ts +++ b/apps/cli/src/commands/secrets/unset/unset.command.ts @@ -2,6 +2,10 @@ import type * as CliCommand from "effect/cli/Command"; import { Argument, Command, Flag } from "effect/cli"; import { Layer } from "effect"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; @@ -34,6 +38,22 @@ export const secretsUnsetCommand = Command.make("unset", config).pipe( description: "Unset multiple secrets", }, ]), + withPermissions( + compose( + { + operations: [ + { + operationId: "v1-list-all-secrets", + kind: "required", + context: "no secret names are given as arguments", + }, + { operationId: "v1-bulk-delete-secrets", kind: "required" }, + ], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => secretsUnset(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/secrets/unset/unset.integration.test.ts b/apps/cli/src/commands/secrets/unset/unset.integration.test.ts index a81da7ffdd..49e86e4503 100644 --- a/apps/cli/src/commands/secrets/unset/unset.integration.test.ts +++ b/apps/cli/src/commands/secrets/unset/unset.integration.test.ts @@ -1,6 +1,6 @@ import { type V1ListAllSecretsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema } from "effect"; import { YesFlag } from "../../../command-internal/global-flags.ts"; import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; @@ -14,10 +14,17 @@ import { useTempWorkdir, withConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { secretsUnset } from "./unset.handler.ts"; type SecretsList = typeof V1ListAllSecretsOutput.Type; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + interface SetupOpts { format?: "text" | "json" | "stream-json"; goOutput?: "pretty" | "json" | "yaml" | "toml" | "env"; @@ -81,6 +88,7 @@ describe("secrets unset integration", () => { expect(deletes).toHaveLength(1); expect(deletes[0]?.body).toEqual(["FOO"]); expect(out.stdoutText).toBe("Finished supabase secrets unset.\n"); + assertPermissionDrift({ command: "secrets unset", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -111,6 +119,8 @@ describe("secrets unset integration", () => { const deletes = api.requests.filter((r) => r.method === "DELETE"); expect(gets).toHaveLength(1); expect(deletes[0]?.body).toEqual(["FOO", "BAR"]); + expect(api.requests.map((r) => r.method)).toEqual(["GET", "DELETE"]); + assertPermissionDrift({ command: "secrets unset", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -123,6 +133,8 @@ describe("secrets unset integration", () => { yield* secretsUnset({ projectRef: Option.none(), names: [] }); expect(out.stderrText).toContain("You have not set any function secrets, nothing to do."); expect(api.requests.filter((r) => r.method === "DELETE")).toHaveLength(0); + expect(api.requests.map((r) => r.method)).toEqual(["GET"]); + assertPermissionDrift({ command: "secrets unset", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -218,6 +230,65 @@ describe("secrets unset integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("lists projects for the picker and fills the real linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const projectUrl = `https://api.supabase.com/v1/projects/${VALID_REF}`; + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.url === projectUrl) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if (request.url === `${projectUrl}/secrets` && request.method === "GET") { + return Effect.succeed( + jsonResponse(request, 200, [ + { name: "FOO", value: "d1" }, + { name: "SUPABASE_AUTH_TOKEN", value: "d2" }, + ]), + ); + } + return Effect.succeed(jsonResponse(request, 200, null)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true, stdoutIsTty: false }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }), + Layer.succeed(YesFlag, true), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* secretsUnset({ projectRef: Option.none(), names: [] }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET ${projectUrl}/secrets`, + `DELETE ${projectUrl}/secrets`, + `GET ${projectUrl}`, + ]); + expect(api.requests[2]?.body).toEqual(["FOO"]); + expect(out.stdoutText).toBe("Finished supabase secrets unset.\n"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "secrets unset", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + it.effect("fails with SecretsListNetworkError on GET failure (empty-args path)", () => { const { layer } = setup({ yes: true, listNetwork: "fail" }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md index 37d4e92f80..46376a7870 100644 --- a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md @@ -12,19 +12,23 @@ ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------ | -| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | -| `/supabase/.temp/linked-project.json` | JSON | best-effort after `--project-ref` resolves | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | +| `/supabase/.temp/linked-project.json` | JSON | best-effort after the project ref resolves — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/snippets/{id}` | Bearer token | none | `{content: {sql, schema_version, favorite?}, id, name, visibility, owner, project, inserted_at, updated_at}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------- | ------------ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/snippets/{id}` | Bearer token | none | `{content: {sql, schema_version, favorite?}, id, name, visibility, owner, project, inserted_at, updated_at}` | +| `GET` | `/v1/projects` | Bearer token | none | project picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | Only `content.sql` is rendered in text mode. The full payload is exposed via `--output-format json`. +The snippet request carries no project ref and no query. The ref is resolved only so the cache fill can run, so the command still fails when no project is linked. + ## Environment Variables | Variable | Purpose | Required? | @@ -45,9 +49,9 @@ Only `content.sql` is rendered in text mode. The full payload is exposed via `-- ## Telemetry Events Fired -| Event | When | Notable properties | -| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` allowed verbatim) | +| Event | When | Notable properties | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` → ``) | ## Output diff --git a/apps/cli/src/commands/snippets/download/download.command.ts b/apps/cli/src/commands/snippets/download/download.command.ts index 16872c7f74..9bb2ae8201 100644 --- a/apps/cli/src/commands/snippets/download/download.command.ts +++ b/apps/cli/src/commands/snippets/download/download.command.ts @@ -1,6 +1,10 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -26,6 +30,13 @@ export const snippetsDownloadCommand = Command.make("download", config).pipe( description: "Download the SQL contents of the given snippet", }, ]), + withPermissions( + compose( + { operations: [{ operationId: "v1-get-a-snippet", kind: "required" }] }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => snippetsDownload(flags).pipe( // No `safeFlags` — `--project-ref` is not on the telemetry-safe list, diff --git a/apps/cli/src/commands/snippets/download/download.integration.test.ts b/apps/cli/src/commands/snippets/download/download.integration.test.ts index 158f149116..380a669304 100644 --- a/apps/cli/src/commands/snippets/download/download.integration.test.ts +++ b/apps/cli/src/commands/snippets/download/download.integration.test.ts @@ -1,16 +1,23 @@ import { type V1GetASnippetOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option, Schema } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { + VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { snippetsDownload } from "./download.handler.ts"; @@ -50,6 +57,7 @@ interface SetupOpts { } const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); const tempRoot = useTempWorkdir("supabase-snippets-download-int-"); @@ -232,6 +240,7 @@ describe("snippets download integration", () => { expect(api.requests[0]?.method).toBe("GET"); expect(api.requests[0]?.url).toContain(`/v1/snippets/${VALID_ID}`); expect(api.requests[0]?.urlParams).toBe(""); + assertPermissionDrift({ command: "snippets download", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -249,12 +258,63 @@ describe("snippets download integration", () => { it.effect("uses --project-ref flag value when resolving the linked-project cache", () => { const flagRef = "zzzzzzzzzzzzzzzzzzzz"; - const { layer, cache } = setup(); + const { layer, cache, api } = setup(); return Effect.gen(function* () { yield* snippetsDownload({ snippetId: VALID_ID, projectRef: Option.some(flagRef) }); // The download endpoint itself takes only the snippet ID, but the // resolved project ref still flows into the linked-project cache write. expect(cache.cached).toBe(true); + expect(api.requests).toHaveLength(1); + assertPermissionDrift({ + command: "snippets download", + activeFlags: { "project-ref": true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists projects for the picker, sends no ref, and fills the real cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, 200, SNIPPET_RESPONSE)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + telemetry: mockTelemetryStateTracked().layer, + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/snippets/${VALID_ID}`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(api.requests[1]?.urlParams).toBe(""); + expect(out.stdoutText).toBe(`${SQL}\n`); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "snippets download", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md index 81dfa17e25..7a1ea7096b 100644 --- a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md @@ -8,26 +8,29 @@ | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | | `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | | `` | XML (UTF-8) | when `--metadata-file` is provided | | `` | JSON | when `--attribute-mapping-file` is provided | ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------ | -| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | -| `/supabase/.temp/linked-project.json` | JSON | best-effort after `--project-ref` resolves | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------------------------- | ------------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -| `POST` | `/v1/projects/{ref}/config/auth/sso/providers` | Bearer token | `{type, metadata_xml?, metadata_url?, domains?, attribute_mapping?, name_id_format?}` | `{id, saml?, domains?, created_at?, updated_at?}` (typed schema) | -| `GET` | `` | none | `Accept: application/xml`, 10s timeout | XML body (UTF-8) — validation when `--skip-url-validation` not set | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on 4xx | -| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------------------------- | ------------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `POST` | `/v1/projects/{ref}/config/auth/sso/providers` | Bearer token | `{type, metadata_xml?, metadata_url?, domains?, attribute_mapping?, name_id_format?}` | `{id, saml?, domains?, created_at?, updated_at?}` (typed schema) | +| `GET` | `` | none | `Accept: application/xml`, 10s timeout | XML body (UTF-8) — validation when `--skip-url-validation` not set | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | -The POST goes through the typed Management API client. `--attribute-mapping-file` is validated +The POST goes through `api.executeRaw` with the typed `v1CreateASsoProvider` operation, which keeps the response body undecoded so structured output emits it as the API returned it; the typed provider schema decodes a copy for the table view. `--attribute-mapping-file` is validated against the API's `attribute_mapping` schema before the request: a key the schema does not model fails with `failed to parse attribute mapping: Expected no excess property at [...]` and nothing is sent. A 2xx response whose body does not match the provider schema fails as diff --git a/apps/cli/src/commands/sso/add/add.command.ts b/apps/cli/src/commands/sso/add/add.command.ts index 42a33a17b0..b431c07105 100644 --- a/apps/cli/src/commands/sso/add/add.command.ts +++ b/apps/cli/src/commands/sso/add/add.command.ts @@ -1,6 +1,11 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { stringSliceFlag } from "../../../command-internal/string-slice-flag.ts"; @@ -70,6 +75,25 @@ export const ssoAddCommand = Command.make("add", config).pipe( description: "Add a new SAML SSO provider", }, ]), + withPermissions( + compose( + { + operations: [{ operationId: "v1-create-a-sso-provider", kind: "required" }], + noApiEffectFlags: [ + "type", + "domains", + "metadata-file", + "metadata-url", + "skip-url-validation", + "attribute-mapping-file", + "name-id-format", + ], + }, + upgradeSuggestion, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => ssoAdd(flags).pipe( withCommandTelemetry({ diff --git a/apps/cli/src/commands/sso/add/add.integration.test.ts b/apps/cli/src/commands/sso/add/add.integration.test.ts index 1f111862bd..f6b6b4a1e7 100644 --- a/apps/cli/src/commands/sso/add/add.integration.test.ts +++ b/apps/cli/src/commands/sso/add/add.integration.test.ts @@ -1,10 +1,21 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Predicate, Stdio } from "effect"; +import { + Cause, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Predicate, + Schema, + Stdio, +} from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, DEFAULT_API_URL, @@ -15,6 +26,11 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { ProfileFlag } from "../../../command-internal/global-flags.ts"; import { EventUpgradeSuggested } from "../../../shared/telemetry/event-catalog.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; @@ -30,6 +46,8 @@ const RESPONSE_PROVIDER = { domains: [{ domain: "example.com" }], }; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-sso-add-int-"); interface SetupOpts { @@ -215,6 +233,58 @@ describe("sso add integration", () => { expect(req).toBeDefined(); expect(req?.url).toContain(`/v1/projects/${VALID_REF}/config/auth/sso/providers`); expect(req?.body).toHaveProperty("type", "saml"); + assertPermissionDrift({ command: "sso add", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the projects for the picker and fills the linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.method === "GET" && request.url === `${DEFAULT_API_URL}/v1/projects`) { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if ( + request.method === "GET" && + request.url === `${DEFAULT_API_URL}/v1/projects/${VALID_REF}` + ) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, 201, RESPONSE_PROVIDER)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }), + Stdio.layerTest({ args: Effect.succeed(["sso", "add", "--type", "saml"]) }), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* ssoAdd(defaultFlags); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${DEFAULT_API_URL}/v1/projects`, + `POST ${DEFAULT_API_URL}/v1/projects/${VALID_REF}/config/auth/sso/providers`, + `GET ${DEFAULT_API_URL}/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain(RESPONSE_PROVIDER.id); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "sso add", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -901,10 +971,16 @@ describe("sso add integration", () => { }); it.effect("fires cli_upgrade_suggested on 404 when entitlement is gated", () => { - const { layer, analytics } = setup({ status: 404, body: {}, upgradeGate: "gated" }); + const { layer, analytics, api } = setup({ status: 404, body: {}, upgradeGate: "gated" }); return Effect.gen(function* () { yield* Effect.exit(ssoAdd(defaultFlags)); expect(analytics.captured.some((c) => c.event === EventUpgradeSuggested)).toBe(true); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `POST ${DEFAULT_API_URL}/v1/projects/${VALID_REF}/config/auth/sso/providers`, + `GET ${DEFAULT_API_URL}/v1/projects/${VALID_REF}`, + `GET ${DEFAULT_API_URL}/v1/organizations/acme/entitlements`, + ]); + assertPermissionDrift({ command: "sso add", requests: api.requests }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md index d850edbc60..9deed5e329 100644 --- a/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md @@ -2,29 +2,40 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------ | -------------------------------------------------------------- | -| `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | +| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | +| `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | -`info` makes **no** API call, so no access-token lookup occurs. +`info` makes no Management API call of its own, but the Management API client is built eagerly, so an +access token is required: a missing one fails before the handler runs. ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------ | -| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | -| `/supabase/.temp/linked-project.json` | JSON | best-effort after `--project-ref` resolves | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | ## API Routes -`info` derives all three URLs locally from the project ref. No Management API -calls are made. +`info` derives all three URLs locally from the project ref and makes no Management API call of its +own. The ref resolution and the cache fill still call the API: + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables -| Variable | Purpose | Required? | -| ------------------ | -------------------------------------------------- | --------------------------- | -| `SUPABASE_PROFILE` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | ## Exit Codes diff --git a/apps/cli/src/commands/sso/info/info.command.ts b/apps/cli/src/commands/sso/info/info.command.ts index f7ef7b1d3c..9cb148d261 100644 --- a/apps/cli/src/commands/sso/info/info.command.ts +++ b/apps/cli/src/commands/sso/info/info.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -25,6 +29,7 @@ export const ssoInfoCommand = Command.make("info", config).pipe( description: "Get SSO info for a project", }, ]), + withPermissions(compose({ operations: [] }, projectRefPicker, linkedProjectCache)), Command.withHandler((flags) => ssoInfo(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), diff --git a/apps/cli/src/commands/sso/info/info.integration.test.ts b/apps/cli/src/commands/sso/info/info.integration.test.ts index c155416dbb..fdb42bac15 100644 --- a/apps/cli/src/commands/sso/info/info.integration.test.ts +++ b/apps/cli/src/commands/sso/info/info.integration.test.ts @@ -1,18 +1,26 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Option } from "effect"; +import { Effect, FileSystem, Option, Schema } from "effect"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, VALID_REF, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { ssoInfo } from "./info.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-sso-info-int-"); interface SetupOpts { @@ -143,11 +151,52 @@ describe("sso info integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("does NOT make any API call", () => { + it.effect("makes no Management API call of its own", () => { const { layer, api } = setup(); return Effect.gen(function* () { yield* ssoInfo({ projectRef: Option.none() }); expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); }); + + it.effect("lists the picker's projects and fills the real cache, with no call of its own", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, 404, {})); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* ssoInfo({ projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain(expectedAcsUrl); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "sso info", requests: api.requests }); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md index 87b04fed16..d112c8e7df 100644 --- a/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md @@ -8,21 +8,24 @@ | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | | `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------ | -| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | -| `/supabase/.temp/linked-project.json` | JSON | best-effort after `--project-ref` resolves | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/config/auth/sso/providers` | Bearer token | none | `{items: [{id, saml?, domains?, created_at?, updated_at?}]}` | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on 4xx | -| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate side-call on 4xx | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/config/auth/sso/providers` | Bearer token | none | `{items: [{id, saml?, domains?, created_at?, updated_at?}]}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables diff --git a/apps/cli/src/commands/sso/list/list.command.ts b/apps/cli/src/commands/sso/list/list.command.ts index a3cd242bae..13905e7e16 100644 --- a/apps/cli/src/commands/sso/list/list.command.ts +++ b/apps/cli/src/commands/sso/list/list.command.ts @@ -1,6 +1,11 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -23,6 +28,14 @@ export const ssoListCommand = Command.make("list", config).pipe( description: "List all SSO providers for a project", }, ]), + withPermissions( + compose( + { operations: [{ operationId: "v1-list-all-sso-provider", kind: "required" }] }, + upgradeSuggestion, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => ssoList(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), diff --git a/apps/cli/src/commands/sso/list/list.integration.test.ts b/apps/cli/src/commands/sso/list/list.integration.test.ts index f3cd584b1f..5430f847a5 100644 --- a/apps/cli/src/commands/sso/list/list.integration.test.ts +++ b/apps/cli/src/commands/sso/list/list.integration.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option, Schema } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, VALID_REF, @@ -12,6 +12,11 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { EventUpgradeSuggested } from "../../../shared/telemetry/event-catalog.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; @@ -37,6 +42,8 @@ const PROVIDER_ITEM = { updated_at: "2023-03-28T13:50:14.464Z", }; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-sso-list-int-"); interface SetupOpts { @@ -45,6 +52,7 @@ interface SetupOpts { status?: number; body?: unknown; network?: "fail"; + projectId?: Option.Option; // Configures the upgrade-gate side-call branches: // "gated" → entitlement says auth.saml_2 has_access=false // "notGated" → entitlement says auth.saml_2 has_access=true @@ -124,7 +132,10 @@ function setup(opts: SetupOpts = {}) { }, }); - const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: opts.projectId, + }); const layer = buildTestRuntime({ out, api, @@ -331,10 +342,16 @@ describe("sso list integration", () => { }); it.effect("does NOT fire cli_upgrade_suggested when entitlement is not gated", () => { - const { layer, analytics } = setup({ status: 404, body: {}, upgradeGate: "notGated" }); + const { layer, analytics, api } = setup({ status: 404, body: {}, upgradeGate: "notGated" }); return Effect.gen(function* () { yield* Effect.exit(ssoList({ projectRef: Option.none() })); expect(analytics.captured.some((c) => c.event === EventUpgradeSuggested)).toBe(false); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/config/auth/sso/providers`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + "GET https://api.supabase.com/v1/organizations/acme/entitlements", + ]); + assertPermissionDrift({ command: "sso list", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -382,9 +399,77 @@ describe("sso list integration", () => { yield* ssoList({ projectRef: Option.none() }); const ssoRequest = api.requests.find((r) => r.url.endsWith(`/config/auth/sso/providers`)); expect(ssoRequest?.method).toBe("GET"); + assertPermissionDrift({ command: "sso list", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("ignores an empty --project-ref and resolves the saved ref without the picker", () => { + const { layer, api } = setup({ projectId: Option.none() }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${tempRoot.current}/supabase/.temp`, { recursive: true }); + yield* fs.writeFileString(`${tempRoot.current}/supabase/.temp/project-ref`, VALID_REF); + yield* ssoList({ projectRef: Option.some("") }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/config/auth/sso/providers`, + ]); + assertPermissionDrift({ + command: "sso list", + activeFlags: { "project-ref": "" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); + it.effect( + "lists the picker's projects, the providers and fills the real linked-project cache", + () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, 200, { items: [PROVIDER_ITEM] })); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* ssoList({ projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/config/auth/sso/providers`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain("0b0d48f6-878b-4190-88d7-2ca33ed800bc"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "sso list", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("flushes telemetry + linked-project cache on success", () => { const { layer, telemetry, cache } = setup(); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md index 89283a9ad5..6fe691460c 100644 --- a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md @@ -8,21 +8,24 @@ | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | | `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------ | -| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | -| `/supabase/.temp/linked-project.json` | JSON | best-effort after `--project-ref` resolves | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ------------------------------------------------------------ | ------------ | ------------ | --------------------------------------------------------- | -| `DELETE` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on 4xx | -| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------------------------------ | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `DELETE` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables diff --git a/apps/cli/src/commands/sso/remove/remove.command.ts b/apps/cli/src/commands/sso/remove/remove.command.ts index 49d70dfb3e..95a41eb24e 100644 --- a/apps/cli/src/commands/sso/remove/remove.command.ts +++ b/apps/cli/src/commands/sso/remove/remove.command.ts @@ -1,6 +1,11 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -29,6 +34,14 @@ export const ssoRemoveCommand = Command.make("remove", config).pipe( description: "Remove an SSO provider by ID", }, ]), + withPermissions( + compose( + { operations: [{ operationId: "v1-delete-a-sso-provider", kind: "required" }] }, + upgradeSuggestion, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => ssoRemove(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), diff --git a/apps/cli/src/commands/sso/remove/remove.integration.test.ts b/apps/cli/src/commands/sso/remove/remove.integration.test.ts index f5c2658897..120099ab96 100644 --- a/apps/cli/src/commands/sso/remove/remove.integration.test.ts +++ b/apps/cli/src/commands/sso/remove/remove.integration.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, VALID_REF, @@ -12,6 +12,11 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { EventUpgradeSuggested } from "../../../shared/telemetry/event-catalog.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; import { ssoRemove } from "./remove.handler.ts"; @@ -24,6 +29,8 @@ const PROVIDER = { domains: [{ domain: "example.com" }], }; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-sso-remove-int-"); interface SetupOpts { @@ -144,6 +151,7 @@ describe("sso remove integration", () => { `/v1/projects/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`, ); expect(out.stdoutText).toContain(VALID_PROVIDER_ID); + assertPermissionDrift({ command: "sso remove", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -176,10 +184,16 @@ describe("sso remove integration", () => { }); it.effect("fires cli_upgrade_suggested when gated on 4xx", () => { - const { layer, analytics } = setup({ status: 404, body: {}, upgradeGate: "gated" }); + const { layer, analytics, api } = setup({ status: 404, body: {}, upgradeGate: "gated" }); return Effect.gen(function* () { yield* Effect.exit(ssoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID })); expect(analytics.captured.some((c) => c.event === EventUpgradeSuggested)).toBe(true); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + "GET https://api.supabase.com/v1/organizations/acme/entitlements", + ]); + assertPermissionDrift({ command: "sso remove", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -233,6 +247,48 @@ describe("sso remove integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("lists the picker's projects, removes the provider and fills the real cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, 200, PROVIDER)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* ssoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain(VALID_PROVIDER_ID); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "sso remove", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + it.effect("flushes telemetry + linked-project cache on success", () => { const { layer, telemetry, cache } = setup(); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md index 7bd38943e9..818ce060af 100644 --- a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md @@ -8,19 +8,22 @@ | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | | `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------ | -| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | -| `/supabase/.temp/linked-project.json` | JSON | best-effort after `--project-ref` resolves | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------------------------ | ------------ | ------------ | ------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------------------------ | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | Note: Unlike `list`/`add`/`update`/`remove`, `show` does **not** make upgrade-gate side-calls. diff --git a/apps/cli/src/commands/sso/show/show.command.ts b/apps/cli/src/commands/sso/show/show.command.ts index 678d1a5ecb..58d90a54f8 100644 --- a/apps/cli/src/commands/sso/show/show.command.ts +++ b/apps/cli/src/commands/sso/show/show.command.ts @@ -1,6 +1,10 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -33,6 +37,16 @@ export const ssoShowCommand = Command.make("show", config).pipe( description: "Show SSO provider details", }, ]), + withPermissions( + compose( + { + operations: [{ operationId: "v1-get-a-sso-provider", kind: "required" }], + noApiEffectFlags: ["metadata"], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => ssoShow(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), diff --git a/apps/cli/src/commands/sso/show/show.integration.test.ts b/apps/cli/src/commands/sso/show/show.integration.test.ts index 971b3464ea..f849e84e8d 100644 --- a/apps/cli/src/commands/sso/show/show.integration.test.ts +++ b/apps/cli/src/commands/sso/show/show.integration.test.ts @@ -1,16 +1,22 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, VALID_REF, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { EventUpgradeSuggested } from "../../../shared/telemetry/event-catalog.ts"; import { ssoShow } from "./show.handler.ts"; @@ -28,6 +34,8 @@ const PROVIDER = { updated_at: "2023-03-28T13:50:14.464Z", }; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-sso-show-int-"); interface SetupOpts { @@ -261,7 +269,7 @@ UpdatedAt = "2023-03-28T13:50:14.464Z" }); it.effect("--metadata short-circuits and prints raw XML", () => { - const { layer, out } = setup(); + const { layer, out, api } = setup(); return Effect.gen(function* () { yield* ssoShow({ projectRef: Option.none(), @@ -269,6 +277,15 @@ UpdatedAt = "2023-03-28T13:50:14.464Z" metadata: true, }); expect(out.stdoutText).toBe('\n'); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`, + ]); + assertPermissionDrift({ + command: "sso show", + activeFlags: { metadata: true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -324,6 +341,53 @@ UpdatedAt = "2023-03-28T13:50:14.464Z" expect(req?.url).toContain( `/v1/projects/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`, ); + assertPermissionDrift({ command: "sso show", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the picker's projects, shows the provider and fills the real cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, 200, PROVIDER)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* ssoShow({ + projectRef: Option.none(), + providerId: VALID_PROVIDER_ID, + metadata: false, + }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain(VALID_PROVIDER_ID); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "sso show", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md index 7316e098db..eb182fd927 100644 --- a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md @@ -8,28 +8,33 @@ | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | | `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | | `` | XML (UTF-8) | when `--metadata-file` is provided | | `` | JSON | when `--attribute-mapping-file` is provided | ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------ | -| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | -| `/supabase/.temp/linked-project.json` | JSON | best-effort after `--project-ref` resolves | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------------------------ | ------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `GET` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | -| `PUT` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | `{metadata_xml?, metadata_url?, domains, attribute_mapping?, name_id_format?}` | `{id, saml?, domains?, ...}` (typed schema) | -| `GET` | `` | none | `Accept: application/xml`, 10s timeout | XML body (UTF-8) — validation when `--skip-url-validation` not set | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on 4xx | -| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate | - -Both the GET and the PUT go through the typed Management API client (the PUT retries on 5xx and -transport failures like any idempotent request). `--attribute-mapping-file` is validated against +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------------------------ | ------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | +| `PUT` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | `{metadata_xml?, metadata_url?, domains, attribute_mapping?, name_id_format?}` | `{id, saml?, domains?, ...}` (typed schema) | +| `GET` | `` | none | `Accept: application/xml`, 10s timeout | XML body (UTF-8) — validation when `--skip-url-validation` not set | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | + +The GET goes through the typed Management API client. The PUT goes through `api.executeRaw` with the +typed `v1UpdateASsoProvider` operation, which keeps the response body undecoded so structured output +emits it as the API returned it; the typed provider schema decodes a copy for the table view (the PUT +retries on 5xx and transport failures like any idempotent request). `--attribute-mapping-file` is validated against the API's `attribute_mapping` schema after the GET and before the PUT: a key the schema does not model fails with `failed to parse attribute mapping: Expected no excess property at [...]`. A 2xx body that does not match the provider schema fails as `SsoUpdateNetworkError` (API-response diff --git a/apps/cli/src/commands/sso/update/update.command.ts b/apps/cli/src/commands/sso/update/update.command.ts index 18ce829833..7be5c75e55 100644 --- a/apps/cli/src/commands/sso/update/update.command.ts +++ b/apps/cli/src/commands/sso/update/update.command.ts @@ -1,6 +1,11 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { stringSliceFlag } from "../../../command-internal/string-slice-flag.ts"; @@ -83,6 +88,29 @@ export const ssoUpdateCommand = Command.make("update", config).pipe( description: "Update an SSO provider's domains", }, ]), + withPermissions( + compose( + { + operations: [ + { operationId: "v1-get-a-sso-provider", kind: "required" }, + { operationId: "v1-update-a-sso-provider", kind: "required" }, + ], + noApiEffectFlags: [ + "domains", + "add-domains", + "remove-domains", + "metadata-file", + "metadata-url", + "skip-url-validation", + "attribute-mapping-file", + "name-id-format", + ], + }, + upgradeSuggestion, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => ssoUpdate(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"], config }), diff --git a/apps/cli/src/commands/sso/update/update.integration.test.ts b/apps/cli/src/commands/sso/update/update.integration.test.ts index 72aad15622..cd86295c79 100644 --- a/apps/cli/src/commands/sso/update/update.integration.test.ts +++ b/apps/cli/src/commands/sso/update/update.integration.test.ts @@ -17,9 +17,15 @@ import { import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; -import { mockAnalytics, mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; +import { + mockAnalytics, + mockOutput, + mockRuntimeInfo, + mockTty, +} from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, + DEFAULT_API_URL, VALID_REF, mockCommandSettings, mockLinkedProjectCacheTracked, @@ -27,6 +33,11 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { ProfileFlag } from "../../../command-internal/global-flags.ts"; import { EventUpgradeSuggested } from "../../../shared/telemetry/event-catalog.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; @@ -47,6 +58,8 @@ const RESPONSE_PROVIDER = { domains: [{ domain: "new.com" }], }; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-sso-update-int-"); interface SetupOpts { @@ -257,17 +270,77 @@ describe("sso update integration", () => { yield* ssoUpdate(defaultFlags); const methods = api.requests.map((r) => r.method); expect(methods.indexOf("GET")).toBeLessThan(methods.indexOf("PUT")); + assertPermissionDrift({ command: "sso update", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the projects for the picker and fills the linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const projectsUrl = `${DEFAULT_API_URL}/v1/projects`; + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.method === "GET" && request.url === projectsUrl) { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.method === "GET" && request.url === `${projectsUrl}/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if (request.method === "GET") { + return Effect.succeed(jsonResponse(request, 200, EXISTING_PROVIDER)); + } + return Effect.succeed(jsonResponse(request, 200, RESPONSE_PROVIDER)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }), + Stdio.layerTest({ args: Effect.succeed(["sso", "update", VALID_PROVIDER_ID]) }), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* ssoUpdate(defaultFlags); + const providerUrl = `${projectsUrl}/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`; + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${projectsUrl}`, + `GET ${providerUrl}`, + `PUT ${providerUrl}`, + `GET ${projectsUrl}/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain(VALID_PROVIDER_ID); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "sso update", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); it.effect("GET 404 → NotFound error", () => { - const { layer } = setup({ getStatus: 404, getBody: {} }); + const { layer, api } = setup({ getStatus: 404, getBody: {} }); return Effect.gen(function* () { const exit = yield* Effect.exit(ssoUpdate(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("SsoUpdateNotFoundError"); } + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${DEFAULT_API_URL}/v1/projects/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`, + `GET ${DEFAULT_API_URL}/v1/projects/${VALID_REF}`, + ]); + assertPermissionDrift({ command: "sso update", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -1583,6 +1656,11 @@ describe("sso update integration", () => { "http://second.example/v1/organizations/acme/entitlements", ); expect(api.requests.some((r) => r.url.startsWith("http://first.example/"))).toBe(false); + assertPermissionDrift({ + command: "sso update", + requests: api.requests, + apiUrl: "http://second.example", + }); }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); }).pipe(Effect.provide(BunServices.layer)), ); diff --git a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md index 8189135c91..03aa55baac 100644 --- a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md @@ -11,41 +11,45 @@ credential, capability-state, and error behavior. ## Files Read -| Path | Format | When | -| --------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (local creds; `[storage.buckets.*]` for bucket auto-create). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | -| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | -| `/supabase/.env*`, `/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | -| upload source files | bytes | upload: sniff (≤512 bytes) + streamed body | +| Path | Format | When | +| ---------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (local creds; `[storage.buckets.*]` for bucket auto-create). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | +| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | +| `/supabase/.temp/project-ref` | plain text | linked path, when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/linked-project.json` | JSON | linked path, existence check before the cache fill | +| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | +| `/supabase/.env*`, `/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | +| upload source files | bytes | upload: sniff (≤512 bytes) + streamed body | ## Files Written -| Path | Format | When | -| ---------------------------------------- | ------ | ------------------------------------------------------ | -| download destination files | bytes | download (single: O_EXCL `wx`; recursive: O_TRUNC `w`) | -| download destination parent dirs | dir | recursive download (`mkdir -p`) | -| `~/.supabase//linked-project.json` | JSON | post-run, linked path | -| `~/.supabase/telemetry.json` | JSON | post-run (always) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| download destination files | bytes | download (single: O_EXCL `wx`; recursive: O_TRUNC `w`) | +| download destination parent dirs | dir | recursive download (`mkdir -p`) | +| `/supabase/.temp/linked-project.json` | JSON | post-run, linked path (`--local` false) — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | +| `~/.supabase/telemetry.json` | JSON | post-run (always) | ## API Routes Auth: `apikey` always; `Authorization: Bearer ` unless the key is `sb_`-prefixed. -| Method | Path | Request body / headers | Response | -| ------ | ----------------------------------------- | ----------------------------------------------------------------------------- | ---------------- | -| `GET` | `/storage/v1/object/{path}` | — (download) | binary stream | -| `POST` | `/storage/v1/object/{path}` | file stream; `Content-Type`, `Cache-Control`, `x-upsert` (recursive only) | — | -| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` (recursive walk + dst detection) | `[{name, id?}]` | -| `GET` | `/storage/v1/bucket` | — (recursive walk to bucket root) | `[{name, id}]` | -| `POST` | `/storage/v1/bucket` | `{name, public?, file_size_limit?, allowed_mime_types?}` (auto-create on 404) | `{name}` | -| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (linked, Management API) | service-role key | +| Method | Path | Request body / headers | Response | +| ------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| `GET` | `/storage/v1/object/{path}` | — (download) | binary stream | +| `POST` | `/storage/v1/object/{path}` | file stream; `Content-Type`, `Cache-Control`, `x-upsert` (recursive only) | — | +| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` (recursive walk + dst detection) | `[{name, id?}]` | +| `GET` | `/storage/v1/bucket` | — (recursive walk to bucket root) | `[{name, id}]` | +| `POST` | `/storage/v1/bucket` | `{name, public?, file_size_limit?, allowed_mime_types?}` (auto-create on 404) | `{name}` | +| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (linked, Management API; skipped when `SUPABASE_AUTH_SERVICE_ROLE_KEY` is set to a non-empty value in the shell env) | service-role key | +| `GET` | `/v1/projects/{ref}` | — (linked, Management API; best-effort cache fill after the ref resolves, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored) | `ref`, `name`, `organization_id`, `organization_slug` | ## Environment Variables -`SUPABASE_AUTH_SERVICE_ROLE_KEY`, `SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_SERVICES_HOSTNAME`, -and the `SUPABASE_API_*` override family are legacy backend only; `SUPABASE_ACCESS_TOKEN`, +`SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_SERVICES_HOSTNAME`, and the `SUPABASE_API_*` +override family are legacy backend only. `SUPABASE_AUTH_SERVICE_ROLE_KEY` is legacy backend only +for `--local`; on the linked path a non-empty shell value skips the api-keys call under either +backend. `SUPABASE_ACCESS_TOKEN`, `SUPABASE_PROJECT_ID`, and `SUPABASE_EXPERIMENTAL_STACK` (backend selection) apply to either backend — same roles as `storage ls`. `SUPABASE_PROJECT_ID`'s linked-ref resolution is superseded by `--project-ref` when set. @@ -99,6 +103,8 @@ backend — same roles as `storage ls`. resolution used above (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies `--linked`: passing it with `--local` is a hard error rather than a silently discarded flag. +- **`--local=false`** / `--no-local` target the linked project: routing follows the parsed + `--local` value, and `--linked` is not read for routing. - Single upload does NOT send `x-upsert`; recursive upload sets it. - `--content-type` overrides the sniffed type; an explicit value is still refined when it is a generic `text/plain`. diff --git a/apps/cli/src/commands/storage/cp/cp.command.ts b/apps/cli/src/commands/storage/cp/cp.command.ts index f988f0b7df..1f1e245ce5 100644 --- a/apps/cli/src/commands/storage/cp/cp.command.ts +++ b/apps/cli/src/commands/storage/cp/cp.command.ts @@ -3,6 +3,8 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; @@ -10,6 +12,7 @@ import { storageGatewayRuntimeLayer } from "../../../command-internal/storage-ru import { storageInvalidJobsMessage } from "../storage.errors.ts"; import { parseUintBase0 } from "../../../command-internal/parse-uint.ts"; import { + STORAGE_PERMISSIONS, StorageLinkedFlagDef, StorageLocalFlagDef, StorageProjectRefFlagDef, @@ -85,6 +88,12 @@ export const storageCpCommand = Command.make("cp", config).pipe( description: "Download a directory from storage", }, ]), + withPermissions( + compose(STORAGE_PERMISSIONS, { + operations: [], + noApiEffectFlags: ["cache-control", "content-type", "jobs"], + }), + ), Command.withHandler((flags) => Effect.gen(function* () { // Gate before the mutex check below; see requireExperimental's doc comment for why. A diff --git a/apps/cli/src/commands/storage/cp/cp.integration.test.ts b/apps/cli/src/commands/storage/cp/cp.integration.test.ts index b0f22e4917..a393340589 100644 --- a/apps/cli/src/commands/storage/cp/cp.integration.test.ts +++ b/apps/cli/src/commands/storage/cp/cp.integration.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Option, Path, Predicate } from "effect"; import { setupStorage, STORAGE_TEST_JWT_SECRET } from "../../../../tests/helpers/storage.ts"; -import { VALID_REF, useTempWorkdir } from "../../../../tests/helpers/command-mocks.ts"; +import { VALID_REF, useTempWorkdir, withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { StackStorageCapabilityError } from "../../../command-internal/stack-storage.ts"; import { generateGoJwt } from "../../../command-internal/go-jwt.ts"; import { StorageUnsupportedOperationError } from "../storage.errors.ts"; @@ -12,6 +13,9 @@ import type { StorageCpFlags } from "./cp.command.ts"; const BUCKET = "/storage/v1/bucket"; const OBJECT = (p: string) => `/storage/v1/object/${p}`; const LIST = (bucket: string) => `/storage/v1/object/list/${bucket}`; +const REVEALED_API_KEYS = `GET https://api.supabase.com/v1/projects/${VALID_REF}/api-keys?reveal=true`; +const PROJECT_LOOKUP = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; +const CACHE_FILE = "supabase/.temp/linked-project.json"; function cpFlags(opts: { src: string; @@ -682,6 +686,43 @@ describe("storage cp", () => { expect(yield* fs.readFileString(path.join(dst, "b.txt"))).toBe("b-content"); }).pipe(Effect.provide(layer)); }); + it.effect("fetches the revealed api keys and fills the cache through the real layers", () => + withEnvVar( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + undefined, + Effect.gen(function* () { + const { layer, requests, managementRequests } = setupStorage(tmp.current, { + recordManagementApi: true, + routes: [{ method: "POST", match: OBJECT("private/readme.md"), body: {} }], + }); + yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString(path.join(tmp.current, "readme.md"), "hello world"); + yield* storageCp( + cpFlags({ + src: path.join(tmp.current, "readme.md"), + dst: "ss:///private/readme.md", + local: false, + jobs: 1, + }), + ); + expect( + managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), + ).toEqual([REVEALED_API_KEYS, PROJECT_LOOKUP]); + const upload = requests.find((r) => r.url.includes(OBJECT("private/readme.md"))); + expect(upload?.method).toBe("POST"); + expect(upload?.url.startsWith(`https://${VALID_REF}.supabase.co`)).toBe(true); + expect(yield* fs.exists(path.join(tmp.current, CACHE_FILE))).toBe(true); + assertPermissionDrift({ + command: "storage cp", + activeFlags: { local: false, jobs: "1" }, + requests: managementRequests, + }); + }).pipe(Effect.provide(layer)); + }), + ), + ); }); describe("stack backend", () => { diff --git a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md index a55de670a8..1f2e8b73ec 100644 --- a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md @@ -10,30 +10,32 @@ credential, capability-state, and error behavior. ## Files Read -| Path | Format | When | -| --------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (local creds/baseUrl; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | -| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | -| `/supabase/.env*`, `/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | +| Path | Format | When | +| ---------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (local creds/baseUrl; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | +| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | +| `/supabase/.temp/project-ref` | plain text | linked path, when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/linked-project.json` | JSON | linked path, existence check before the cache fill | +| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | +| `/supabase/.env*`, `/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | ## Files Written -| Path | Format | When | -| ---------------------------------------- | ------ | --------------------------------- | -| `~/.supabase//linked-project.json` | JSON | post-run, linked path (ref cache) | -| `~/.supabase/telemetry.json` | JSON | post-run (always) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | post-run, linked path (`--local` false) — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | +| `~/.supabase/telemetry.json` | JSON | post-run (always) | ## API Routes Auth: `apikey` header always; `Authorization: Bearer ` unless the key is `sb_`-prefixed. -| Method | Path | Request body | Response (used) | -| ------ | ----------------------------------------- | --------------------------------------- | ------------------------------- | -| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` | `[{name, id?}]` (id null ⇒ dir) | -| `GET` | `/storage/v1/bucket` | — | `[{name, id}]` | -| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (linked, Management API) | api-key list → service-role key | +| Method | Path | Request body | Response (used) | +| ------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` | `[{name, id?}]` (id null ⇒ dir) | +| `GET` | `/storage/v1/bucket` | — | `[{name, id}]` | +| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (linked, Management API; skipped when `SUPABASE_AUTH_SERVICE_ROLE_KEY` is set to a non-empty value in the shell env) | api-key list → service-role key | +| `GET` | `/v1/projects/{ref}` | — (linked, Management API; best-effort cache fill after the ref resolves, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored) | `ref`, `name`, `organization_id`, `organization_slug` | ## Environment Variables @@ -100,6 +102,8 @@ No custom storage telemetry events. resolution used above (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies `--linked`: passing it with `--local` is a hard error rather than a silently discarded flag. +- **`--local=false`** / `--no-local` target the linked project: routing follows the parsed + `--local` value, and `--linked` is not read for routing. - `--local` / `--linked` are mutually exclusive; `--local` routes to the local stack, otherwise the linked project is used. They are declared **per-leaf** (not as `storage`-group scoped globals) because Effect CLI requires global-flag names to be diff --git a/apps/cli/src/commands/storage/ls/ls.command.ts b/apps/cli/src/commands/storage/ls/ls.command.ts index 69cccf48df..ceeec4688f 100644 --- a/apps/cli/src/commands/storage/ls/ls.command.ts +++ b/apps/cli/src/commands/storage/ls/ls.command.ts @@ -3,11 +3,13 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { storageGatewayRuntimeLayer } from "../../../command-internal/storage-runtime.layer.ts"; import { + STORAGE_PERMISSIONS, StorageLinkedFlagDef, StorageLocalFlagDef, StorageProjectRefFlagDef, @@ -41,6 +43,7 @@ export const storageLsCommand = Command.make("ls", config).pipe( description: "List objects at a storage path", }, ]), + withPermissions(STORAGE_PERMISSIONS), Command.withHandler((flags) => Effect.gen(function* () { // Gate before the mutex check below; see requireExperimental's doc comment for why. diff --git a/apps/cli/src/commands/storage/ls/ls.integration.test.ts b/apps/cli/src/commands/storage/ls/ls.integration.test.ts index 8c0e344c19..029b3bff95 100644 --- a/apps/cli/src/commands/storage/ls/ls.integration.test.ts +++ b/apps/cli/src/commands/storage/ls/ls.integration.test.ts @@ -4,6 +4,7 @@ import { Cause, Effect, Exit, FileSystem, Option, Path, Predicate } from "effect import { VALID_REF, useTempWorkdir, withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; import { setupStorage, STORAGE_TEST_JWT_SECRET } from "../../../../tests/helpers/storage.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { StackStorageCapabilityError, StackStorageUnavailableError, @@ -15,6 +16,9 @@ import { generateGoJwt } from "../../../command-internal/go-jwt.ts"; const BUCKET = "/storage/v1/bucket"; const LIST = (bucket: string) => `/storage/v1/object/list/${bucket}`; +const REVEALED_API_KEYS = `GET https://api.supabase.com/v1/projects/${VALID_REF}/api-keys?reveal=true`; +const PROJECT_LOOKUP = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; +const CACHE_FILE = "supabase/.temp/linked-project.json"; const writeAncestorConfig = Effect.fnUntraced(function* (root: string, toml: string) { const fs = yield* FileSystem.FileSystem; @@ -447,6 +451,119 @@ describe("storage ls", () => { }).pipe(Effect.provide(BunServices.layer)); }, ); + it.effect("fetches the revealed api keys and fills the cache through the real layers", () => + withEnvVar( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + undefined, + Effect.gen(function* () { + const { layer, out, requests, managementRequests } = setupStorage(tmp.current, { + recordManagementApi: true, + routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], + }); + const fs = yield* FileSystem.FileSystem; + yield* storageLs(lsFlags({ local: false })).pipe(Effect.provide(layer)); + expect( + managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), + ).toEqual([REVEALED_API_KEYS, PROJECT_LOOKUP]); + expect(out.stdoutText).toBe("remote/\n"); + expect(requests[0]?.url.startsWith(`https://${VALID_REF}.supabase.co`)).toBe(true); + expect(yield* fs.exists(`${tmp.current}/${CACHE_FILE}`)).toBe(true); + assertPermissionDrift({ + command: "storage ls", + activeFlags: { local: false }, + requests: managementRequests, + }); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); + + it.effect("asks for no api keys when SUPABASE_AUTH_SERVICE_ROLE_KEY is set", () => + withEnvVar( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + "env-service-role-key", + Effect.gen(function* () { + const { layer, requests, managementRequests } = setupStorage(tmp.current, { + recordManagementApi: true, + routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], + }); + yield* storageLs(lsFlags({ local: false })).pipe(Effect.provide(layer)); + expect(requests[0]?.headers["apikey"]).toBe("env-service-role-key"); + expect(managementRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + PROJECT_LOOKUP, + ]); + assertPermissionDrift({ + command: "storage ls", + activeFlags: { local: false }, + requests: managementRequests, + }); + }), + ), + ); + + it.effect("makes no Management API request for --local", () => + withEnvVar( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + undefined, + Effect.gen(function* () { + const { layer, out, requests, managementRequests } = setupStorage(tmp.current, { + toml: 'project_id = "test"\n[api]\nport = 65432\n', + recordManagementApi: true, + routes: [{ method: "GET", match: BUCKET, body: [{ name: "test", id: "test" }] }], + }); + const fs = yield* FileSystem.FileSystem; + yield* storageLs(lsFlags({ local: true })).pipe(Effect.provide(layer)); + expect(out.stdoutText).toBe("test/\n"); + expect(requests).toHaveLength(1); + expect(requests[0]?.url.startsWith("http://127.0.0.1:65432")).toBe(true); + expect(managementRequests).toEqual([]); + expect(yield* fs.exists(`${tmp.current}/${CACHE_FILE}`)).toBe(false); + assertPermissionDrift({ + command: "storage ls", + activeFlags: { local: true }, + requests: managementRequests, + }); + expect(() => + assertPermissionDrift({ + command: "storage ls", + activeFlags: { local: true }, + requests: [ + { + method: "GET", + url: `https://api.supabase.com/v1/projects/${VALID_REF}/api-keys`, + urlParams: "reveal=true", + }, + ], + }), + ).toThrow(/condition doesn't hold/); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); + + it.effect("--linked=false still targets the linked project", () => + withEnvVar( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + undefined, + Effect.gen(function* () { + const { layer, out, requests, managementRequests } = setupStorage(tmp.current, { + recordManagementApi: true, + routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], + }); + yield* storageLs({ ...lsFlags({ local: false }), linked: false }).pipe( + Effect.provide(layer), + ); + expect(out.stdoutText).toBe("remote/\n"); + expect(requests[0]?.url.startsWith(`https://${VALID_REF}.supabase.co`)).toBe(true); + expect( + managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), + ).toEqual([REVEALED_API_KEYS, PROJECT_LOOKUP]); + assertPermissionDrift({ + command: "storage ls", + activeFlags: { linked: false, local: false }, + requests: managementRequests, + }); + }), + ), + ); }); describe("stack backend", () => { diff --git a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md index 6303501305..606a75d753 100644 --- a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md @@ -12,35 +12,39 @@ credential, capability-state, and error behavior. ## Files Read -| Path | Format | When | -| --------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | -| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | -| `/supabase/.env*`, `/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | +| Path | Format | When | +| ---------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | +| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | +| `/supabase/.temp/project-ref` | plain text | linked path, when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/linked-project.json` | JSON | linked path, existence check before the cache fill | +| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | +| `/supabase/.env*`, `/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | ## Files Written -| Path | Format | When | -| ---------------------------------------- | ------ | --------------------- | -| `~/.supabase//linked-project.json` | JSON | post-run, linked path | -| `~/.supabase/telemetry.json` | JSON | post-run (always) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | post-run, linked path (`--local` false) — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | +| `~/.supabase/telemetry.json` | JSON | post-run (always) | ## API Routes Auth: `apikey` always; `Authorization: Bearer ` unless the key is `sb_`-prefixed. -| Method | Path | Request body | Response | -| ------ | ----------------------------------------- | ------------------------------------------------------------ | ---------------- | -| `POST` | `/storage/v1/object/move` | `{bucketId, sourceKey, destinationKey}` | `{message}` | -| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` (recursive fallback) | `[{name, id?}]` | -| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (linked, Management API) | service-role key | +| Method | Path | Request body | Response | +| ------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| `POST` | `/storage/v1/object/move` | `{bucketId, sourceKey, destinationKey}` | `{message}` | +| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` (recursive fallback) | `[{name, id?}]` | +| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (linked, Management API; skipped when `SUPABASE_AUTH_SERVICE_ROLE_KEY` is set to a non-empty value in the shell env) | service-role key | +| `GET` | `/v1/projects/{ref}` | — (linked, Management API; best-effort cache fill after the ref resolves, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored) | `ref`, `name`, `organization_id`, `organization_slug` | ## Environment Variables -`SUPABASE_AUTH_SERVICE_ROLE_KEY`, `SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_SERVICES_HOSTNAME`, -and the `SUPABASE_API_*` override family are legacy backend only; `SUPABASE_ACCESS_TOKEN`, +`SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_SERVICES_HOSTNAME`, and the `SUPABASE_API_*` +override family are legacy backend only. `SUPABASE_AUTH_SERVICE_ROLE_KEY` is legacy backend only +for `--local`; on the linked path a non-empty shell value skips the api-keys call under either +backend. `SUPABASE_ACCESS_TOKEN`, `SUPABASE_PROJECT_ID`, and `SUPABASE_EXPERIMENTAL_STACK` (backend selection) apply to either backend — same roles as `storage ls`. `SUPABASE_PROJECT_ID`'s linked-ref resolution is superseded by `--project-ref` when set. @@ -94,6 +98,8 @@ backend — same roles as `storage ls`. resolution used above (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies `--linked`: passing it with `--local` is a hard error rather than a silently discarded flag. +- **`--local=false`** / `--no-local` target the linked project: routing follows the parsed + `--local` value, and `--linked` is not read for routing. - Both `src` and `dst` must be `ss://` URLs (a stricter parse than the lenient one `cp` uses). - **Stack backend (`--local`).** The Storage endpoint comes from the selected composition's diff --git a/apps/cli/src/commands/storage/mv/mv.command.ts b/apps/cli/src/commands/storage/mv/mv.command.ts index 7af3de30d1..3cbe023e5a 100644 --- a/apps/cli/src/commands/storage/mv/mv.command.ts +++ b/apps/cli/src/commands/storage/mv/mv.command.ts @@ -3,11 +3,13 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { storageGatewayRuntimeLayer } from "../../../command-internal/storage-runtime.layer.ts"; import { + STORAGE_PERMISSIONS, StorageLinkedFlagDef, StorageLocalFlagDef, StorageProjectRefFlagDef, @@ -39,6 +41,7 @@ export const storageMvCommand = Command.make("mv", config).pipe( description: "Recursively move a directory within storage", }, ]), + withPermissions(STORAGE_PERMISSIONS), Command.withHandler((flags) => Effect.gen(function* () { // Gate before the mutex check below; see requireExperimental's doc comment for why. diff --git a/apps/cli/src/commands/storage/mv/mv.integration.test.ts b/apps/cli/src/commands/storage/mv/mv.integration.test.ts index 323bf404d4..907dce9606 100644 --- a/apps/cli/src/commands/storage/mv/mv.integration.test.ts +++ b/apps/cli/src/commands/storage/mv/mv.integration.test.ts @@ -1,15 +1,19 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option, Predicate } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Predicate } from "effect"; import { StackStorageCapabilityError } from "../../../command-internal/stack-storage.ts"; import { generateGoJwt } from "../../../command-internal/go-jwt.ts"; import { setupStorage, STORAGE_TEST_JWT_SECRET } from "../../../../tests/helpers/storage.ts"; -import { VALID_REF, useTempWorkdir } from "../../../../tests/helpers/command-mocks.ts"; +import { VALID_REF, useTempWorkdir, withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { storageMv } from "./mv.handler.ts"; import type { StorageMvFlags } from "./mv.command.ts"; const MOVE = "/storage/v1/object/move"; const LIST = (bucket: string) => `/storage/v1/object/list/${bucket}`; +const REVEALED_API_KEYS = `GET https://api.supabase.com/v1/projects/${VALID_REF}/api-keys?reveal=true`; +const PROJECT_LOOKUP = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; +const CACHE_FILE = "supabase/.temp/linked-project.json"; function mvFlags(opts: { src: string; @@ -409,6 +413,41 @@ describe("storage mv", () => { expect(linkedCache.cached).toBe(true); }); }); + it.effect("fetches the revealed api keys and fills the cache through the real layers", () => + withEnvVar( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + undefined, + Effect.gen(function* () { + const { layer, out, requests, managementRequests } = setupStorage(tmp.current, { + recordManagementApi: true, + routes: [{ method: "POST", match: MOVE, body: { message: "Successfully moved" } }], + }); + yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* storageMv( + mvFlags({ src: "ss:///private/a", dst: "ss:///private/b", local: false }), + ); + expect( + managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), + ).toEqual([REVEALED_API_KEYS, PROJECT_LOOKUP]); + const move = requests.find((r) => r.url.includes(MOVE)); + expect(move?.url.startsWith(`https://${VALID_REF}.supabase.co`)).toBe(true); + expect(move?.body).toEqual({ + bucketId: "private", + sourceKey: "a", + destinationKey: "b", + }); + expect(out.stderrText).toContain("Successfully moved"); + expect(yield* fs.exists(`${tmp.current}/${CACHE_FILE}`)).toBe(true); + assertPermissionDrift({ + command: "storage mv", + activeFlags: { local: false }, + requests: managementRequests, + }); + }).pipe(Effect.provide(layer)); + }), + ), + ); }); describe("stack backend", () => { diff --git a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md index 0080ac45df..3df8fb7e3f 100644 --- a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md @@ -12,37 +12,41 @@ credential, capability-state, and error behavior. ## Files Read -| Path | Format | When | -| --------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | -| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | -| `/supabase/.env*`, `/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878) and, on both backends, for `env(VAR)` interpolation while loading `config.toml`; on the local, legacy-backend path also the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | +| Path | Format | When | +| ---------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | +| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | +| `/supabase/.temp/project-ref` | plain text | linked path, when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/linked-project.json` | JSON | linked path, existence check before the cache fill | +| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | +| `/supabase/.env*`, `/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878) and, on both backends, for `env(VAR)` interpolation while loading `config.toml`; on the local, legacy-backend path also the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | ## Files Written -| Path | Format | When | -| ---------------------------------------- | ------ | --------------------- | -| `~/.supabase//linked-project.json` | JSON | post-run, linked path | -| `~/.supabase/telemetry.json` | JSON | post-run (always) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | post-run, linked path (`--local` false) — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | +| `~/.supabase/telemetry.json` | JSON | post-run (always) | ## API Routes Auth: `apikey` always; `Authorization: Bearer ` unless the key is `sb_`-prefixed. -| Method | Path | Request body | Response | -| -------- | ----------------------------------------- | -------------------------------------------------------- | ---------------- | -| `DELETE` | `/storage/v1/object/{bucket}` | `{prefixes}` (chunked by 1000) | `[{name, ...}]` | -| `DELETE` | `/storage/v1/bucket/{id}` | — (recursive on an empty prefix) | `{message}` | -| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` (recursive walk) | `[{name, id?}]` | -| `GET` | `/storage/v1/bucket` | — (no-args + `-r`: delete all buckets) | `[{name, id}]` | -| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (linked, Management API) | service-role key | +| Method | Path | Request body | Response | +| -------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| `DELETE` | `/storage/v1/object/{bucket}` | `{prefixes}` (chunked by 1000) | `[{name, ...}]` | +| `DELETE` | `/storage/v1/bucket/{id}` | — (recursive on an empty prefix) | `{message}` | +| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` (recursive walk) | `[{name, id?}]` | +| `GET` | `/storage/v1/bucket` | — (no-args + `-r`: delete all buckets) | `[{name, id}]` | +| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (linked, Management API; skipped when `SUPABASE_AUTH_SERVICE_ROLE_KEY` is set to a non-empty value in the shell env) | service-role key | +| `GET` | `/v1/projects/{ref}` | — (linked, Management API; best-effort cache fill after the ref resolves, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored) | `ref`, `name`, `organization_id`, `organization_slug` | ## Environment Variables -`SUPABASE_AUTH_SERVICE_ROLE_KEY`, `SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_SERVICES_HOSTNAME`, -and the `SUPABASE_API_*` override family are legacy backend only; `SUPABASE_ACCESS_TOKEN`, +`SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_SERVICES_HOSTNAME`, and the `SUPABASE_API_*` +override family are legacy backend only. `SUPABASE_AUTH_SERVICE_ROLE_KEY` is legacy backend only +for `--local`; on the linked path a non-empty shell value skips the api-keys call under either +backend. `SUPABASE_ACCESS_TOKEN`, `SUPABASE_PROJECT_ID`, `SUPABASE_EXPERIMENTAL_STACK` (backend selection), and `SUPABASE_YES` (auto-confirm) apply to either backend — read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` files @@ -107,6 +111,8 @@ Requires `--yes`/`SUPABASE_YES` on the same terms as `json`. resolution used above (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies `--linked`: passing it with `--local` is a hard error rather than a silently discarded flag. +- **`--local=false`** / `--no-local` target the linked project: routing follows the parsed + `--local` value, and `--linked` is not read for routing. - Validation (missing bucket, missing `-r` for a directory) runs before any network call; the no-args missing-`-r` error runs after the client is built, and keeps beating the confirmation refusal in every output mode. diff --git a/apps/cli/src/commands/storage/rm/rm.command.ts b/apps/cli/src/commands/storage/rm/rm.command.ts index 35c3babe0c..e518565512 100644 --- a/apps/cli/src/commands/storage/rm/rm.command.ts +++ b/apps/cli/src/commands/storage/rm/rm.command.ts @@ -2,12 +2,14 @@ import { Effect, Layer } from "effect"; import { Argument, Command, Flag } from "effect/cli"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { storageGatewayRuntimeLayer } from "../../../command-internal/storage-runtime.layer.ts"; import { + STORAGE_PERMISSIONS, StorageLinkedFlagDef, StorageLocalFlagDef, StorageProjectRefFlagDef, @@ -43,6 +45,7 @@ export const storageRmCommand = Command.make("rm", config).pipe( description: "Remove multiple files from storage", }, ]), + withPermissions(STORAGE_PERMISSIONS), Command.withHandler((flags) => Effect.gen(function* () { // Gate before the mutex check below; see requireExperimental's doc comment for why. diff --git a/apps/cli/src/commands/storage/rm/rm.integration.test.ts b/apps/cli/src/commands/storage/rm/rm.integration.test.ts index dec78d98de..b6226f8c83 100644 --- a/apps/cli/src/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/commands/storage/rm/rm.integration.test.ts @@ -14,6 +14,7 @@ import { withConfigEnv, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { storageRm } from "./rm.handler.ts"; const writeAncestorConfig = Effect.fnUntraced(function* (root: string, toml: string) { @@ -28,6 +29,9 @@ const BUCKET = "/storage/v1/bucket"; const DELETE_OBJECT = (bucket: string) => `/storage/v1/object/${bucket}`; const DELETE_BUCKET = (bucket: string) => `/storage/v1/bucket/${bucket}`; const LIST = (bucket: string) => `/storage/v1/object/list/${bucket}`; +const REVEALED_API_KEYS = `GET https://api.supabase.com/v1/projects/${VALID_REF}/api-keys?reveal=true`; +const PROJECT_LOOKUP = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; +const CACHE_FILE = "supabase/.temp/linked-project.json"; function prefixesOf(body: unknown): ReadonlyArray | undefined { return Predicate.hasProperty(body, "prefixes") && Array.isArray(body.prefixes) @@ -874,6 +878,44 @@ describe("storage rm", () => { expect(success?.data?.["buckets_deleted"]).toEqual([]); }); }); + it.effect("fetches the revealed api keys and fills the cache through the real layers", () => + withEnvVar( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + undefined, + Effect.gen(function* () { + const { layer, out, requests, managementRequests } = setupStorage(tmp.current, { + recordManagementApi: true, + yes: true, + routes: [ + { method: "DELETE", match: DELETE_OBJECT("private"), body: [{ name: "a.pdf" }] }, + ], + }); + yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* storageRm({ + files: ["ss:///private/a.pdf"], + recursive: false, + linked: true, + local: false, + projectRef: Option.none(), + }); + expect( + managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), + ).toEqual([REVEALED_API_KEYS, PROJECT_LOOKUP]); + const del = requests.find((r) => r.method === "DELETE"); + expect(del?.url.startsWith(`https://${VALID_REF}.supabase.co`)).toBe(true); + expect(del?.body).toEqual({ prefixes: ["a.pdf"] }); + expect(out.stderrText).toContain("Deleting objects: [a.pdf]"); + expect(yield* fs.exists(`${tmp.current}/${CACHE_FILE}`)).toBe(true); + assertPermissionDrift({ + command: "storage rm", + activeFlags: { local: false }, + requests: managementRequests, + }); + }).pipe(Effect.provide(layer)); + }), + ), + ); }); describe("stack backend", () => { diff --git a/apps/cli/src/commands/storage/storage.flags.ts b/apps/cli/src/commands/storage/storage.flags.ts index 785377cf77..2407c59d07 100644 --- a/apps/cli/src/commands/storage/storage.flags.ts +++ b/apps/cli/src/commands/storage/storage.flags.ts @@ -1,7 +1,10 @@ import { Effect } from "effect"; import { Flag } from "effect/cli"; +import { compose, withCondition } from "../../command-internal/command-permissions/model.ts"; import { changedLinkedLocalFlags } from "../../command-internal/db-target-flags.ts"; +import { storageCredentials } from "../../command-internal/storage-credentials.ts"; +import { linkedProjectCache } from "../../telemetry/linked-project-cache.layer.ts"; import { StorageMutuallyExclusiveFlagsError } from "./storage.errors.ts"; /** @@ -28,6 +31,12 @@ export const StorageProjectRefFlagDef = Flag.String("project-ref").pipe( Flag.optional, ); +/** The Management API calls every `storage` subcommand makes on the linked project. */ +export const STORAGE_PERMISSIONS = compose( + { operations: [], noApiEffectFlags: ["linked", "project-ref", "recursive"] }, + withCondition(compose(storageCredentials, linkedProjectCache), { flag: "local", is: false }), +); + /** * Mutual-exclusion check for `--linked`/`--local`, rejected before the handler body * so it never fires `cli_command_executed`. Each leaf must call this before diff --git a/apps/cli/tests/helpers/notebooks.ts b/apps/cli/tests/helpers/notebooks.ts index 697a79e448..e4f460fe2a 100644 --- a/apps/cli/tests/helpers/notebooks.ts +++ b/apps/cli/tests/helpers/notebooks.ts @@ -6,8 +6,10 @@ import type * as HttpClientError from "effect/http/HttpClientError"; import type * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as UrlParams from "effect/http/UrlParams"; +import { CommandPlatformApiFactory } from "../../src/auth/command-platform-api-factory.service.ts"; import { CommandPlatformApi } from "../../src/auth/command-platform-api.service.ts"; import { ProjectRefNotLinkedError } from "../../src/config/project-ref.errors.ts"; +import { projectRefLayer } from "../../src/config/project-ref.layer.ts"; import { ProjectRefResolver } from "../../src/config/project-ref.service.ts"; import { OutputFlag } from "../../src/command-internal/global-flags.ts"; import { @@ -16,7 +18,8 @@ import { mockCommandSettings, transportFailure, } from "./command-mocks.ts"; -import { mockOutput, mockContextualAnalytics, mockProcessControl } from "./mocks.ts"; +import { mockOutput, mockContextualAnalytics, mockProcessControl, mockTty } from "./mocks.ts"; +import { realLinkedProjectCache } from "./permission-real-layers.ts"; import { commandRuntimeLayer } from "../../src/shared/runtime/command-runtime.layer.ts"; /** @@ -124,10 +127,19 @@ function mockNotebooksHttp(routes: NotebooksHttpRoutes) { return { layer: Layer.mergeAll(apiLayer, httpClientLayer), + httpClientLayer, requests, get routeKeys(): Array { return requests.map((request) => `${request.method} ${new URL(request.url).pathname}`); }, + /** The requests in the shape the permission drift helpers take. */ + get driftRequests() { + return requests.map(({ method, url, query }) => ({ + method, + url, + urlParams: query.toString(), + })); + }, }; } @@ -245,6 +257,7 @@ export interface NotebooksSetupOptions { readonly workdir: string; readonly format?: "text" | "json" | "stream-json"; readonly interactive?: boolean; + /** Picks the mock resolver's linkage; ignored with `realLayers`, where the workdir's ref file decides. */ readonly linked?: boolean; /** Answers the reconciliation prompt: `"keep"`, `"copy"` or `"delete"`. */ readonly promptSelectResponses?: ReadonlyArray; @@ -253,6 +266,12 @@ export interface NotebooksSetupOptions { readonly goOutput?: "env" | "pretty" | "json" | "toml" | "yaml" | "table" | "csv"; readonly command?: "pull" | "push"; readonly args?: ReadonlyArray; + /** + * Runs the real `projectRefLayer` (an unlinked workdir reaches its picker) and the real + * linked-project cache, so their `GET /v1/projects` and `GET /v1/projects/{ref}` calls go + * through `routes`. Linkage then comes from the workdir's `.temp/project-ref`, not `linked`. + */ + readonly realLayers?: boolean; } export function setupNotebooks(options: NotebooksSetupOptions) { @@ -267,6 +286,28 @@ export function setupNotebooks(options: NotebooksSetupOptions) { const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); + const cliSettings = + options.realLayers === true + ? mockCommandSettings({ workdir: options.workdir, projectId: Option.none() }) + : mockCommandSettings({ workdir: options.workdir }); + const projectRef = + options.realLayers === true + ? projectRefLayer.pipe( + Layer.provide( + Layer.succeed(CommandPlatformApiFactory, { + make: CommandPlatformApi.pipe(Effect.provide(http.layer)), + }), + ), + Layer.provide(cliSettings), + Layer.provide(mockTty({ stdinIsTty: true })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ) + : testProjectRefLayer(options.linked !== false); + const linkedProjectCache = + options.realLayers === true + ? realLinkedProjectCache({ httpClient: http.httpClientLayer, cliSettings }) + : cache.layer; const analytics = mockContextualAnalytics(); const process = mockProcessControl(); const command = options.command ?? "pull"; @@ -280,10 +321,10 @@ export function setupNotebooks(options: NotebooksSetupOptions) { layer: Layer.mergeAll( out.layer, http.layer, - mockCommandSettings({ workdir: options.workdir }), - testProjectRefLayer(options.linked !== false), + cliSettings, + projectRef, telemetry.layer, - cache.layer, + linkedProjectCache, analytics.layer, process.layer, commandRuntimeLayer(["notebooks", command]).pipe(Layer.provide(BunServices.layer)), diff --git a/apps/cli/tests/helpers/permission-pending-commands.ts b/apps/cli/tests/helpers/permission-pending-commands.ts index 967e64c87b..4aa439fbe0 100644 --- a/apps/cli/tests/helpers/permission-pending-commands.ts +++ b/apps/cli/tests/helpers/permission-pending-commands.ts @@ -20,12 +20,6 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "db branch switch", "db remote changes", - "domains activate", - "domains create", - "domains delete", - "domains get", - "domains reverify", - "encryption get-root-key", "encryption update-root-key", @@ -59,9 +53,6 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "network-restrictions get", "network-restrictions update", - "notebooks pull", - "notebooks push", - "orgs create", "orgs list", @@ -76,23 +67,11 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "pull", - "secrets set", - "secrets unset", - "services", - "snippets download", - "ssl-enforcement get", "ssl-enforcement update", - "sso add", - "sso info", - "sso list", - "sso remove", - "sso show", - "sso update", - "stack destroy", "stack list", "stack logs", @@ -108,11 +87,6 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "stop", - "storage cp", - "storage ls", - "storage mv", - "storage rm", - "telemetry disable", "telemetry enable", "telemetry status", diff --git a/apps/cli/tests/helpers/storage.ts b/apps/cli/tests/helpers/storage.ts index ff93ba4079..c8be85648a 100644 --- a/apps/cli/tests/helpers/storage.ts +++ b/apps/cli/tests/helpers/storage.ts @@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; -import { Effect, Layer, Option, Redacted, Stream } from "effect"; +import { ConfigProvider, Effect, Layer, Option, Redacted, Stream } from "effect"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import type { @@ -33,9 +33,11 @@ import { transportFailure, mockCommandSettings, mockLinkedProjectCacheTracked, + mockCommandPlatformApi, mockCommandPlatformApiService, mockTelemetryStateTracked, } from "./command-mocks.ts"; +import { projectResponse, realLinkedProjectCache } from "./permission-real-layers.ts"; interface StorageRoute { readonly method: string; @@ -79,6 +81,8 @@ export interface SetupStorageOptions { readonly explicitWorkdir?: boolean; readonly stackBackend?: boolean; readonly stackApi?: SetupStorageStackApiOptions; + /** Records the Management API requests through the real factory and runs the real linked-project cache. */ + readonly recordManagementApi?: boolean; } export interface SetupStorageStackApiOptions { @@ -417,19 +421,42 @@ export function setupStorage(workdir: string, opts: SetupStorageOptions) { }, }); + const recorded = + opts.recordManagementApi === true + ? mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.method === "GET" && request.url.endsWith(`/v1/projects/${projectRefRef}`) + ? jsonResponse(request, 200, projectResponse(projectRefRef)) + : request.method === "GET" && + request.url.endsWith(`/v1/projects/${projectRefRef}/api-keys`) + ? jsonResponse(request, 200, opts.apiKeys ?? defaultApiKeys) + : jsonResponse(request, 404, { message: "no mock route" }), + ), + }) + : undefined; + const stackApi = buildStorageStackApi(workdir, opts.stackApi); + const cliSettings = mockCommandSettings({ + workdir, + explicitWorkdir: opts.explicitWorkdir ?? false, + }); const layer = Layer.mergeAll( out.layer, httpLayer, telemetry.layer, - linkedCache.layer, - mockCommandSettings({ workdir, explicitWorkdir: opts.explicitWorkdir ?? false }), + recorded === undefined + ? linkedCache.layer + : realLinkedProjectCache({ httpClient: recorded.httpClientLayer, cliSettings }), + cliSettings, BunServices.layer, projectRefLayer, - Layer.succeed(CommandPlatformApiFactory, { - make: CommandPlatformApi.pipe(Effect.provide(managementApi.layer)), - }), + recorded === undefined + ? Layer.succeed(CommandPlatformApiFactory, { + make: CommandPlatformApi.pipe(Effect.provide(managementApi.layer)), + }) + : recorded.factoryLayer, Layer.succeed(YesFlag, opts.yes ?? false), // `storage rm` confirms deletions via `promptYesNo`, answered by `confirm`; // other storage commands ignore it. @@ -446,7 +473,22 @@ export function setupStorage(workdir: string, opts: SetupStorageOptions) { unusedStackServices, stackApi.layer, ...(opts.stackBackend === true ? [stackBackendLayer("stack")] : []), + ...(recorded === undefined + ? [] + : [ + ConfigProvider.layer( + ConfigProvider.fromEnvRecord(process.env, { preserveEmptyStrings: true }), + ), + ]), ); - return { layer, out, requests, telemetry, linkedCache, stackCalls: stackApi.stackCalls }; + return { + layer, + out, + requests, + telemetry, + linkedCache, + stackCalls: stackApi.stackCalls, + managementRequests: recorded?.requests ?? [], + }; } From 8a15a618139bc20eec3f9af6879423b23665c82a Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Thu, 15 Oct 2026 07:55:56 +0000 Subject: [PATCH 10/26] chore(cli): map projects, orgs, link and bootstrap command permissions (#7170) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps the Management API operations behind the projects, organizations and linking commands, so scoped access tokens can be given matching permissions. This changes no command behaviour. **Commands:** `projects api-keys|create|delete|list`, `orgs create|list`, `link`, `unlink`, `bootstrap`, `services` (removed from the pending list). **New shared block:** `organizationPicker` (`projects.prompt.ts`): the organization listing made when no organization ID is given. **Declarations** - `projects api-keys`: the keys read, with `reveal=true` when `--reveal` is set; the project picker and the cache fill. - `projects create`: the create call, the organization listing (via the picker, when `--interactive` allows a prompt), the cache fill. - `projects delete`: the delete call and a project listing for its own picker (it ignores the env and `.temp` ref, so it doesn't reuse the shared picker), the cache fill. - `projects list`, `orgs create|list`: their single call; `list` also fills the cache for a linked ref. - `link`: project, API keys (`reveal=true`), storage and pooler reads, the branch listings, the picker, the cache fill. `unlink` makes no API calls. - `bootstrap`: organization listing (always, since it passes no org ID), project create, API keys, health, storage and pooler reads, cache fill. The pooler read is required because the migration push depends on the pooler URL it saves on IPv4-only networks. No login call is declared: with no token the command fails before the login runs. - `services`: the linked project's API keys and project reads (both best-effort), cache fill. **Conditions:** `--reveal` is tested by value. `--interactive` is optional, so it is declared as two entries (absent, and present with "is true" in the context). Interactive-terminal, config and argument-shape prerequisites are contexts. **Worth knowing** - `bootstrap`'s browser login is unreachable when no token resolves; the platform-API layer fails first. - `link --password` and `SUPABASE_DB_PASSWORD` have no effect. - Not covered by a drift check: `link`'s cache inclusion and its second branch-listing entry (a required entry for the same operation shadows them), and `bootstrap`'s pooler entry (asserted from the handler). **Docs:** wrong rows corrected in the projects, bootstrap and link `SIDE_EFFECTS.md` files (environment variable name, create body, org listing, bootstrap request body and cache-fill attribution, the `--password` row). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 --- .../command-internal/organization-picker.ts | 10 ++ .../src/commands/bootstrap/SIDE_EFFECTS.md | 26 +-- .../commands/bootstrap/bootstrap.command.ts | 27 +++ ...ootstrap.workdir-cache.integration.test.ts | 18 ++ apps/cli/src/commands/link/SIDE_EFFECTS.md | 2 +- apps/cli/src/commands/link/link.command.ts | 35 ++++ .../commands/link/link.integration.test.ts | 158 ++++++++++++++++++ .../commands/orgs/create/create.command.ts | 2 + .../orgs/create/create.integration.test.ts | 2 + .../src/commands/orgs/list/list.command.ts | 2 + .../orgs/list/list.integration.test.ts | 2 + .../cli/src/commands/projects/SIDE_EFFECTS.md | 33 ++-- .../projects/api-keys/api-keys.command.ts | 22 +++ .../api-keys/api-keys.integration.test.ts | 94 ++++++++++- .../projects/create/create.command.ts | 37 ++++ .../create/create.integration.test.ts | 124 +++++++++++++- .../projects/delete/delete.command.ts | 19 +++ .../delete/delete.integration.test.ts | 65 ++++++- .../commands/projects/list/list.command.ts | 12 ++ .../projects/list/list.integration.test.ts | 53 +++++- .../src/commands/services/services.command.ts | 16 ++ .../services/services.integration.test.ts | 18 ++ .../cli/src/commands/unlink/unlink.command.ts | 2 + .../helpers/permission-pending-commands.ts | 16 -- 24 files changed, 743 insertions(+), 52 deletions(-) create mode 100644 apps/cli/src/command-internal/organization-picker.ts diff --git a/apps/cli/src/command-internal/organization-picker.ts b/apps/cli/src/command-internal/organization-picker.ts new file mode 100644 index 0000000000..0d9c25e19b --- /dev/null +++ b/apps/cli/src/command-internal/organization-picker.ts @@ -0,0 +1,10 @@ +import { block } from "./command-permissions/model.ts"; + +/** The organization listing made when no organization ID is given, shared by `projects create` and `bootstrap`. */ +export const organizationPicker = block("organization-picker", [ + { + operationId: "v1-list-all-organizations", + kind: "required", + when: [{ flag: "org-id", present: false }], + }, +]); diff --git a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md index 54ac163ec0..e1e17c0bd8 100644 --- a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md @@ -41,16 +41,17 @@ staying in effect. ## API Routes -| Method | Path | Auth | Notes | -| --------------- | ----------------------------------------------------------------------------------------- | ------------------------------ | -------------------------------------------------------------------------------------------- | -| `GET` | `api.github.com/repos/supabase-community/supabase-samples/contents/samples.json?ref=main` | optional `Bearer GITHUB_TOKEN` | base64 `content` → `{samples:[…]}` | -| `GET` | `api.github.com/repos///contents/?ref=` + raw `download_url` | optional `Bearer GITHUB_TOKEN` | template download (BFS, concurrency 5) | -| `GET` | `/v1/organizations` | Bearer | interactive org picker (create core) | -| `POST` | `/v1/projects` | Bearer | `{name, organization_slug, db_pass, region?, desired_instance_size?, template_url?}` → `201` | -| `GET` | `/v1/projects/{ref}/api-keys` | Bearer | retried with exponential backoff (no `reveal`) | -| `GET` | `/v1/projects/{ref}` + storage/pooler config + tenant version probes | Bearer / service key | `link.LinkServices` (best-effort) | -| `GET` | `/v1/projects/{ref}/health?services=db` | Bearer | retried with exponential backoff | -| login endpoints | — | — | ensure-login browser flow (token miss) | +| Method | Path | Auth | Notes | +| --------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------ | +| `GET` | `api.github.com/repos/supabase-community/supabase-samples/contents/samples.json?ref=main` | optional `Bearer GITHUB_TOKEN` | base64 `content` → `{samples:[…]}` | +| `GET` | `api.github.com/repos///contents/?ref=` + raw `download_url` | optional `Bearer GITHUB_TOKEN` | template download (BFS, concurrency 5) | +| `GET` | `/v1/organizations` | Bearer | organization listing, always made before the org prompt (bootstrap passes no org ID) | +| `POST` | `/v1/projects` | Bearer | `{name, organization_slug, db_pass, region, template_url?}` → `201` | +| `GET` | `/v1/projects/{ref}/api-keys` | Bearer | retried with exponential backoff (no `reveal`) | +| `GET` | `/v1/projects/{ref}/config/storage`, `/v1/projects/{ref}/config/database/pooler` + tenant version probes | Bearer / anon key (tenant probes) | `link.LinkServices` (best-effort) | +| `GET` | `/v1/projects/{ref}` | Bearer | linked-project cache fill (`Effect.ensuring`, best-effort) | +| `GET` | `/v1/projects/{ref}/health?services=db` | Bearer | retried with exponential backoff | +| login endpoints | — | — | ensure-login browser flow (token miss) | The native push step fires **no Management API routes of its own**. Its connection is resolved separately from (not reused from) the naive one written to `.env` above: `resolveLinkedConn` @@ -84,8 +85,9 @@ neither branch ever reaches the temp-login-role/Management-API path a passwordle - `cli_command_executed` — once (via `withCommandTelemetry`). - `cli_login_completed` — once, **only** on the browser-login path (token miss). - **No `cli_project_linked`** — bootstrap only links services (not a full `link` run), so it - deliberately skips the project-linked telemetry, status check, and the `linked-project.json` - temp write that the standalone `link` command performs. + deliberately skips the project-linked telemetry and the status check that the standalone `link` + command performs. Its `linked-project.json` comes only from the post-run cache fill (see Files + Written), not from a link step. - `create` fires no custom event. - The native push step (`dbPushCore`) fires **no telemetry of its own** — it is the bare handler function, not `db push`'s own `push.command.ts` wrapper (the thing that fires diff --git a/apps/cli/src/commands/bootstrap/bootstrap.command.ts b/apps/cli/src/commands/bootstrap/bootstrap.command.ts index e277b96f81..4defe08de1 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.command.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.command.ts @@ -2,6 +2,12 @@ import { Layer } from "effect"; import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../command-internal/command-permissions/model.ts"; +import { projectApiKeys } from "../../command-internal/get-api-keys.ts"; +import { linkServices } from "../../command-internal/link-services-core.ts"; +import { linkedProjectCache } from "../../telemetry/linked-project-cache.layer.ts"; +import { organizationPicker } from "../../command-internal/organization-picker.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { cliConfigProviderLayer } from "../../shared/config/cli-config-provider.layer.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; @@ -25,6 +31,27 @@ export type BootstrapFlags = CliCommand.Command.Config.Infer; export const bootstrapCommand = Command.make("bootstrap", config).pipe( Command.withDescription("Bootstrap a Supabase project from a starter template."), Command.withShortDescription("Bootstrap a Supabase project from a starter template"), + withPermissions( + compose( + { + operations: [ + { operationId: "v1-create-a-project", kind: "required" }, + { operationId: "v1-get-services-health", kind: "required" }, + { + operationId: "v1-get-pooler-config", + kind: "required", + context: + "the new project's direct database host is unreachable (IPv4-only network): the migration push connects through the pooler URL this call saves, and without it fails", + }, + ], + noApiEffectFlags: ["password"], + }, + organizationPicker, + projectApiKeys, + linkServices, + linkedProjectCache, + ), + ), Command.withHandler((flags) => // Go marks no bootstrap flag `markFlagTelemetrySafe`, so no `safeFlags`. bootstrap(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), diff --git a/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts index 2b937a39df..14ebb3a93c 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts @@ -33,6 +33,7 @@ import { } from "../../command-internal/global-flags.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { SuccessTrailer, successTrailerLayer } from "../../shared/cli/success-trailer.ts"; +import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; import { DbConnection, type PgConnInput } from "../../command-internal/db-connection.service.ts"; import { debugLoggerLayer } from "../../command-internal/debug-logger.layer.ts"; import { identityStitchLayer } from "../../command-internal/identity-stitch.ts"; @@ -312,6 +313,23 @@ describe("bootstrap linked-project cache location", () => { // With no `supabase/seed.sql` file, the seed glob matches nothing, so this line only // prints when `includeSeed` is true. expect(out.stderrText).toContain("Seed files are up to date."); + + // The tenant service probes share the recording client; only the Management API + // requests are mapped. + expect( + api.requests + .filter(({ url }) => url.startsWith("https://api.supabase.com")) + .map(({ method, url }) => `${method} ${url.slice("https://api.supabase.com".length)}`), + ).toEqual([ + "GET /v1/organizations", + "POST /v1/projects", + `GET /v1/projects/${VALID_REF}/api-keys`, + `GET /v1/projects/${VALID_REF}/config/storage`, + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + `GET /v1/projects/${VALID_REF}/health`, + `GET /v1/projects/${VALID_REF}`, + ]); + assertPermissionDrift({ command: "bootstrap", requests: api.requests, exact: true }); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); }); diff --git a/apps/cli/src/commands/link/SIDE_EFFECTS.md b/apps/cli/src/commands/link/SIDE_EFFECTS.md index 2ebd02d3b2..1bbb86f0b9 100644 --- a/apps/cli/src/commands/link/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/link/SIDE_EFFECTS.md @@ -106,7 +106,7 @@ Tenant service gateway (`https://.`, `apikey: ` + | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `SUPABASE_PROJECT_ID` | link-target resolution: `[ref-or-branch]` positional → `--project-ref` → env → TTY prompt (CLI-2167 adds the positional ahead of the flag). Also the 1st parent-project candidate for a TS-only branch-name lookup (CLI-2167). | | `SUPABASE_ACCESS_TOKEN` | Management API bearer auth (env → keyring → `~/.supabase/access-token`) | -| `SUPABASE_DB_PASSWORD` | bound to `--password`; **accepted but a no-op** for `link` (the DB-connection path that would consume it is dead code in Go) | +| `SUPABASE_DB_PASSWORD` | **no-op** for `link`: neither this variable nor `--password` is read (the DB-connection path that would consume them is dead code in Go) | ## Exit Codes diff --git a/apps/cli/src/commands/link/link.command.ts b/apps/cli/src/commands/link/link.command.ts index a6d1294eff..da2ba02398 100644 --- a/apps/cli/src/commands/link/link.command.ts +++ b/apps/cli/src/commands/link/link.command.ts @@ -3,6 +3,11 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { PROJECT_REF_PATTERN } from "../../config/project-ref.service.ts"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withContext } from "../../command-internal/command-permissions/model.ts"; +import { linkServices } from "../../command-internal/link-services-core.ts"; +import { projectRefPicker } from "../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; @@ -61,6 +66,36 @@ export const linkCommand = Command.make("link", config).pipe( description: "Link to a branch of the currently linked project by name", }, ]), + withPermissions( + compose( + { + operations: [ + { + operationId: "v1-list-all-branches", + kind: "required", + context: + "the [ref-or-branch] argument or --project-ref is a branch name or UUID, not a 20-letter project ref", + }, + { operationId: "v1-get-project", kind: "required" }, + { + operationId: "v1-get-project-api-keys", + kind: "required", + request: { query: { reveal: "true" } }, + }, + { + operationId: "v1-list-all-branches", + kind: "best-effort", + context: + "the project lookup returns 404 (the ref is a branch), the ref was not resolved from a branch name, and supabase/.temp/linked-project.json names a different project", + }, + ], + noApiEffectFlags: ["password"], + }, + withContext(projectRefPicker, "no [ref-or-branch] argument"), + linkServices, + linkedProjectCache, + ), + ), Command.withHandler(linkHandler), Command.provide(managementApiRuntimeLayer(["link"])), ); diff --git a/apps/cli/src/commands/link/link.integration.test.ts b/apps/cli/src/commands/link/link.integration.test.ts index c6ab16f356..e57e936012 100644 --- a/apps/cli/src/commands/link/link.integration.test.ts +++ b/apps/cli/src/commands/link/link.integration.test.ts @@ -23,10 +23,13 @@ import { mockAnalytics, mockContextualAnalytics, mockOutput, + mockTty, } from "../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, + mockCommandPlatformApi, statusCodeFailure, transportFailure, mockCommandSettings, @@ -35,6 +38,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; import { link } from "./link.handler.ts"; import { linkHandler } from "./link.command.ts"; import type { LinkFlags } from "./link.command.ts"; @@ -1521,3 +1525,157 @@ describe("link integration", () => { ); }); }); + +// Records the Management API requests `link` makes, which the service-level mock above cannot. +describe("link permissions", () => { + const API = "https://api.supabase.com"; + + function setupRecording( + opts: { + readonly selectResponses?: ReadonlyArray; + } = {}, + ) { + const out = mockOutput({ format: "text", promptSelectResponses: opts.selectResponses }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + const { pathname } = new URL(request.url); + if (request.method !== "GET") { + return jsonResponse(request, 404, { message: "unexpected request" }); + } + if (pathname === "/v1/projects") { + return jsonResponse(request, 200, [HEALTHY_PROJECT]); + } + const match = /^\/v1\/projects\/([a-z]{20})(\/.*)?$/.exec(pathname); + if (match === null) { + // Tenant service probes: unreachable, which the link tolerates. + return jsonResponse(request, 404, {}); + } + const [, ref, rest = ""] = match; + if (rest === "") { + return ref === BRANCH_PROJECT_REF + ? jsonResponse(request, 404, { message: "not found" }) + : jsonResponse(request, 200, { ...HEALTHY_PROJECT, id: ref, ref }); + } + if (rest === "/branches") return jsonResponse(request, 200, [LINK_BRANCH]); + if (rest === "/api-keys") return jsonResponse(request, 200, SERVICE_KEYS); + if (rest === "/config/storage") { + return jsonResponse(request, 200, { + migrationVersion: "2026-01-01-000000", + }); + } + if (rest === "/config/database/pooler") { + return jsonResponse(request, 200, POOLER_PRIMARY); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }), + tty: mockTty({ stdinIsTty: true }), + }); + return { layer, api, out, workdir: tempRoot.current }; + } + + const managementCalls = (api: { requests: ReadonlyArray<{ method: string; url: string }> }) => + api.requests + .filter(({ url }) => url.startsWith(API)) + .map(({ method, url }) => `${method} ${url.slice(API.length)}`); + + it.effect("links by --project-ref, reading the project, its keys, storage and the pooler", () => { + const { layer, api, workdir } = setupRecording(); + return Effect.gen(function* () { + yield* link(flags()); + + expect(managementCalls(api)).toEqual([ + `GET /v1/projects/${VALID_REF}`, + `GET /v1/projects/${VALID_REF}/api-keys`, + `GET /v1/projects/${VALID_REF}/config/storage`, + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + ]); + expect(api.requests.find(({ url }) => url.endsWith("/api-keys"))?.urlParams).toContain( + "reveal=true", + ); + expect(yield* readTemp(workdir, "project-ref")).toBe(VALID_REF); + assertPermissionDrift({ + command: "link", + activeFlags: { "project-ref": true, "skip-pooler": false }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the projects for the picker when no ref is given on a TTY", () => { + const { layer, api } = setupRecording({ selectResponses: [VALID_REF] }); + return Effect.gen(function* () { + yield* link(flags({ projectRef: Option.none() })); + + expect(managementCalls(api)).toEqual([ + "GET /v1/projects", + `GET /v1/projects/${VALID_REF}`, + `GET /v1/projects/${VALID_REF}/api-keys`, + `GET /v1/projects/${VALID_REF}/config/storage`, + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + ]); + assertPermissionDrift({ + command: "link", + activeFlags: { "skip-pooler": false }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("resolves a branch name through the linked parent's branch list", () => { + const { layer, api, workdir } = setupRecording(); + return Effect.gen(function* () { + yield* writeLinkedParentRef(workdir, PARENT_REF); + + yield* link(flags({ refOrBranch: Option.some("feature-branch"), projectRef: Option.none() })); + + expect(managementCalls(api)).toEqual([ + `GET /v1/projects/${PARENT_REF}/branches`, + `GET /v1/projects/${BRANCH_PROJECT_REF}`, + `GET /v1/projects/${BRANCH_PROJECT_REF}/api-keys`, + `GET /v1/projects/${BRANCH_PROJECT_REF}/config/storage`, + `GET /v1/projects/${BRANCH_PROJECT_REF}/config/database/pooler`, + ]); + assertPermissionDrift({ + command: "link", + activeFlags: { "skip-pooler": false }, + requests: api.requests, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "verifies a cached parent against its branch list when linking a raw branch ref", + () => { + const { layer, api, workdir } = setupRecording(); + return Effect.gen(function* () { + yield* writeLinkedProjectCacheFile(workdir, linkedProjectCacheJson(PARENT_REF)); + + yield* link(flags({ projectRef: Option.some(BRANCH_PROJECT_REF) })); + + expect(managementCalls(api)).toEqual([ + `GET /v1/projects/${BRANCH_PROJECT_REF}`, + `GET /v1/projects/${BRANCH_PROJECT_REF}/api-keys`, + `GET /v1/projects/${BRANCH_PROJECT_REF}/config/storage`, + `GET /v1/projects/${BRANCH_PROJECT_REF}/config/database/pooler`, + `GET /v1/projects/${PARENT_REF}/branches`, + ]); + assertPermissionDrift({ + command: "link", + activeFlags: { "project-ref": true, "skip-pooler": false }, + requests: api.requests, + }); + }).pipe(Effect.provide(layer)); + }, + ); +}); diff --git a/apps/cli/src/commands/orgs/create/create.command.ts b/apps/cli/src/commands/orgs/create/create.command.ts index 683f8f0e75..a07d5cd20d 100644 --- a/apps/cli/src/commands/orgs/create/create.command.ts +++ b/apps/cli/src/commands/orgs/create/create.command.ts @@ -1,6 +1,7 @@ import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -16,6 +17,7 @@ export type OrgsCreateFlags = CliCommand.Command.Config.Infer; export const orgsCreateCommand = Command.make("create", config).pipe( Command.withDescription("Create an organization for the logged-in user."), Command.withShortDescription("Create an organization"), + withPermissions({ operations: [{ operationId: "v1-create-an-organization", kind: "required" }] }), Command.withHandler((flags) => orgsCreate(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/orgs/create/create.integration.test.ts b/apps/cli/src/commands/orgs/create/create.integration.test.ts index 6d4737aa8f..37cf713c83 100644 --- a/apps/cli/src/commands/orgs/create/create.integration.test.ts +++ b/apps/cli/src/commands/orgs/create/create.integration.test.ts @@ -10,6 +10,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { orgsCreate } from "./create.handler.ts"; type CreatedOrganization = typeof V1CreateAnOrganizationOutput.Type; @@ -82,6 +83,7 @@ describe("orgs create integration", () => { expect(api.requests[0]?.method).toBe("POST"); expect(api.requests[0]?.url).toContain("/v1/organizations"); expect(api.requests[0]?.body).toEqual({ name: "Acme" }); + assertPermissionDrift({ command: "orgs create", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/orgs/list/list.command.ts b/apps/cli/src/commands/orgs/list/list.command.ts index 8c8cf207f8..b1784e15f7 100644 --- a/apps/cli/src/commands/orgs/list/list.command.ts +++ b/apps/cli/src/commands/orgs/list/list.command.ts @@ -1,6 +1,7 @@ import { Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -12,6 +13,7 @@ export type OrgsListFlags = CliCommand.Command.Config.Infer; export const orgsListCommand = Command.make("list", config).pipe( Command.withDescription("List all organizations the logged-in user belongs."), Command.withShortDescription("List all organizations"), + withPermissions({ operations: [{ operationId: "v1-list-all-organizations", kind: "required" }] }), Command.withHandler((flags) => orgsList(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/orgs/list/list.integration.test.ts b/apps/cli/src/commands/orgs/list/list.integration.test.ts index e64dd8cd9d..68b5f2b008 100644 --- a/apps/cli/src/commands/orgs/list/list.integration.test.ts +++ b/apps/cli/src/commands/orgs/list/list.integration.test.ts @@ -10,6 +10,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { orgsList } from "./list.handler.ts"; type Organizations = typeof V1ListAllOrganizationsOutput.Type; @@ -185,6 +186,7 @@ describe("orgs list integration", () => { expect(api.requests).toHaveLength(1); expect(api.requests[0]?.method).toBe("GET"); expect(api.requests[0]?.url).toContain("/v1/organizations"); + assertPermissionDrift({ command: "orgs list", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/projects/SIDE_EFFECTS.md b/apps/cli/src/commands/projects/SIDE_EFFECTS.md index 93c64cae1a..146dec6a9f 100644 --- a/apps/cli/src/commands/projects/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/projects/SIDE_EFFECTS.md @@ -27,21 +27,22 @@ subcommand's own `SIDE_EFFECTS.md`. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ----------------------------- | ------------ | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| `GET` | `/v1/projects` | Bearer token | — | `[{id, ref, organization_slug, name, region, created_at, status, database}]` | -| `POST` | `/v1/projects` | Bearer token | `{name, organization_slug, db_pass, region?, desired_instance_size?}` (JSON) | `{id, ref, organization_slug, name, region, created_at, status}` | -| `DELETE` | `/v1/projects/{ref}` | Bearer token | — | `{id, ref, name}`; `404` → does-not-exist | -| `GET` | `/v1/projects/{ref}/api-keys` | Bearer token | — | `[{name, api_key?}]` | -| `GET` | `/v1/organizations` | Bearer token | — | `[{id, slug, name}]` — `create` interactive org prompt only | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ----------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | — | `[{id, ref, organization_slug, name, region, created_at, status, database}]` | +| `POST` | `/v1/projects` | Bearer token | `{name, organization_slug, db_pass, region?, desired_instance_size?, high_availability?, release_channel?, postgres_engine?}` (JSON) | `{id, ref, organization_slug, name, region, created_at, status}` | +| `DELETE` | `/v1/projects/{ref}` | Bearer token | — | `{id, ref, name}`; `404` → does-not-exist | +| `GET` | `/v1/projects/{ref}/api-keys` | Bearer token | — | `[{name, api_key?}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | — | linked-project cache fill, best-effort (`Effect.ensuring`) | +| `GET` | `/v1/organizations` | Bearer token | — | `[{id, slug, name}]` — `create` interactive org prompt only | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_REF` | linked project ref (via the config layer) | no (used by `list` marker / `api-keys` ref / `delete` unlink) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | linked project ref (via the config layer) | no (used by the `list` marker and the `api-keys` ref; `delete` ignores it and reads only `supabase/.temp/project-ref`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | > `DB_PASSWORD` is **not** consumed by `projects create`. @@ -80,10 +81,10 @@ subcommand's own `SIDE_EFFECTS.md`. in ANSI; this port emits plain text. The old renderer disabled color when stdout/stderr was not a TTY, so piped / CI output matches byte-for-byte; only the interactive-terminal appearance differs. -- **`create` linked cache:** the new project ref is cached on success; - `delete` also caches the resolved ref, even though the project is gone — the - cache is a telemetry-group record, separate from the `supabase/.temp` link - removed during unlink. +- **`create` linked cache:** the new project ref is cached on success. `delete` + first removes `supabase/.temp` when the deleted ref is the linked one, then makes + a best-effort cache fill (`supabase/.temp/linked-project.json`); the project + lookup it needs returns 404 for a deleted project, so nothing is written. - **`create` non-interactive errors:** consolidates what used to be per-flag "required flag(s) … not set" errors into a single `ProjectsCreateMissingArgError` that lists every missing item at once (a deliberate UX improvement over the diff --git a/apps/cli/src/commands/projects/api-keys/api-keys.command.ts b/apps/cli/src/commands/projects/api-keys/api-keys.command.ts index 8563b6e518..66a51081b6 100644 --- a/apps/cli/src/commands/projects/api-keys/api-keys.command.ts +++ b/apps/cli/src/commands/projects/api-keys/api-keys.command.ts @@ -1,5 +1,10 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withCondition } from "../../../command-internal/command-permissions/model.ts"; +import { projectApiKeys } from "../../../command-internal/get-api-keys.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -30,6 +35,23 @@ export const projectsApiKeysCommand = Command.make("api-keys", config).pipe( description: "List API keys with the secret keys revealed in full", }, ]), + withPermissions( + compose( + { + operations: [ + { + operationId: "v1-get-project-api-keys", + kind: "required", + when: [{ flag: "reveal", is: true }], + request: { query: { reveal: "true" } }, + }, + ], + }, + withCondition(projectApiKeys, { flag: "reveal", is: false }), + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => projectsApiKeys(flags).pipe( // `reveal` is intentionally not in `safeFlags`: it is a boolean flag, and diff --git a/apps/cli/src/commands/projects/api-keys/api-keys.integration.test.ts b/apps/cli/src/commands/projects/api-keys/api-keys.integration.test.ts index 7154087f03..8f8ff6e33a 100644 --- a/apps/cli/src/commands/projects/api-keys/api-keys.integration.test.ts +++ b/apps/cli/src/commands/projects/api-keys/api-keys.integration.test.ts @@ -1,15 +1,18 @@ -import type { V1GetProjectApiKeysOutput } from "@supabase/api/effect"; +import type { V1GetProjectApiKeysOutput, V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockCommandPlatformApi, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { projectsApiKeys } from "./api-keys.handler.ts"; type ApiKeys = typeof V1GetProjectApiKeysOutput.Type; @@ -25,6 +28,26 @@ const REVEALED_KEYS: ApiKeys = [ ]; const FLAG_REF = "qrstuvwxyzabcdefghij"; +const API = "https://api.supabase.com"; + +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; const tempRoot = useTempWorkdir("supabase-projects-apikeys-int-"); @@ -89,6 +112,12 @@ describe("projects api-keys integration", () => { return Effect.gen(function* () { yield* projectsApiKeys({ projectRef: Option.none(), reveal: false }); expect(api.requests[0]?.urlWithParams).not.toContain("reveal"); + assertPermissionDrift({ + command: "projects api-keys", + activeFlags: { reveal: false }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -97,6 +126,12 @@ describe("projects api-keys integration", () => { return Effect.gen(function* () { yield* projectsApiKeys({ projectRef: Option.none(), reveal: true }); expect(api.requests[0]?.urlWithParams).toContain("reveal=true"); + assertPermissionDrift({ + command: "projects api-keys", + activeFlags: { reveal: true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -227,4 +262,57 @@ describe("projects api-keys integration", () => { } }).pipe(Effect.provide(layer)); }); + + it.effect("lists the keys of the project chosen in the picker and fills the real cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "GET" && request.url === `${API}/v1/projects`) { + return jsonResponse(request, 200, [PROJECT]); + } + if (request.method === "GET" && request.url === `${API}/v1/projects/${VALID_REF}`) { + return jsonResponse(request, 200, PROJECT); + } + if (request.url === `${API}/v1/projects/${VALID_REF}/api-keys`) { + return jsonResponse(request, 200, SAMPLE_KEYS); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* projectsApiKeys({ projectRef: Option.none(), reveal: false }); + + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${API}/v1/projects`, + `GET ${API}/v1/projects/${VALID_REF}/api-keys`, + `GET ${API}/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain("anon-secret"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "projects api-keys", + activeFlags: { reveal: false }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/cli/src/commands/projects/create/create.command.ts b/apps/cli/src/commands/projects/create/create.command.ts index 7ca2da77c0..73e5e60e8e 100644 --- a/apps/cli/src/commands/projects/create/create.command.ts +++ b/apps/cli/src/commands/projects/create/create.command.ts @@ -1,6 +1,14 @@ import { V1CreateAProjectInput } from "@supabase/api/effect"; import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withContext, +} from "../../../command-internal/command-permissions/model.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { organizationPicker } from "../../../command-internal/organization-picker.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -102,6 +110,13 @@ const config = { Flag.withHidden, ), }; +// `--interactive` is read as `interactive ?? true`: absent and `true` can prompt, `false` cannot; +// an optional boolean can't be tested with `is`, so the present entry names the value in its context. +const interactivePicker = withContext( + organizationPicker, + "an interactive terminal (stdin TTY and text output)", +); + export type ProjectsCreateFlags = CliCommand.Command.Config.Infer; export const projectsCreateCommand = Command.make("create", config).pipe( @@ -114,6 +129,28 @@ export const projectsCreateCommand = Command.make("create", config).pipe( description: "Create a new project", }, ]), + withPermissions( + compose( + { + operations: [{ operationId: "v1-create-a-project", kind: "required" }], + noApiEffectFlags: [ + "db-password", + "region", + "size", + "high-availability", + "release-channel", + "postgres-engine", + "plan", + ], + }, + withCondition(interactivePicker, { flag: "interactive", present: false }), + withCondition(withContext(interactivePicker, "--interactive is true, not false"), { + flag: "interactive", + present: true, + }), + linkedProjectCache, + ), + ), Command.withHandler((flags) => projectsCreate(flags).pipe( // `high-availability` is omitted from `safeFlags` since boolean flags are always logged diff --git a/apps/cli/src/commands/projects/create/create.integration.test.ts b/apps/cli/src/commands/projects/create/create.integration.test.ts index 1d58d2da68..eeaa30d0ea 100644 --- a/apps/cli/src/commands/projects/create/create.integration.test.ts +++ b/apps/cli/src/commands/projects/create/create.integration.test.ts @@ -1,6 +1,6 @@ import type { OrganizationResponseV1_Output, V1CreateAProjectOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option, Schema } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema } from "effect"; import { CliError, CliOutput, Command } from "effect/cli"; import { mockOutput, mockTelemetryRuntime, mockTty } from "../../../../tests/helpers/mocks.ts"; @@ -11,6 +11,7 @@ import { type ApiResponse, type HttpMethod, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, @@ -18,6 +19,12 @@ import { useTempWorkdir, withConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; +import { + assertBlockDrift, + assertPermissionDrift, +} from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; +import { organizationPicker } from "../../../command-internal/organization-picker.ts"; import { projectsCreateCommand, type ProjectsCreateFlags } from "./create.command.ts"; import { projectsCreate } from "./create.handler.ts"; @@ -52,6 +59,8 @@ const BASE_FLAGS: ProjectsCreateFlags = { const tempRoot = useTempWorkdir("supabase-projects-create-int-"); const jsonText = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); +const API = "https://api.supabase.com"; interface SetupOpts { readonly format?: "text" | "json" | "stream-json"; @@ -130,6 +139,15 @@ describe("projects create integration", () => { ); expect(out.stdoutText).toContain("REFERENCE ID"); expect(out.stdoutText).toContain("East US (North Virginia)"); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `POST ${API}/v1/projects`, + ]); + assertPermissionDrift({ + command: "projects create", + activeFlags: { "org-id": true, "db-password": true, region: true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -369,9 +387,44 @@ describe("projects create integration", () => { }); expect(out.stderrText).toContain("Selected org-id:"); expect(out.stderrText).toContain("Selected region:"); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${API}/v1/organizations`, + `POST ${API}/v1/projects`, + ]); + assertPermissionDrift({ command: "projects create", requests: api.requests }); + assertBlockDrift(organizationPicker, { requests: api.requests.slice(0, 1), exact: true }); }).pipe(Effect.provide(layer)); }); + it.effect( + "lists the organizations when --interactive is passed on a TTY without --org-id", + () => { + const { layer, api } = setup({ + stdinIsTty: true, + promptSelectResponses: ["org-abc"], + }); + return Effect.gen(function* () { + yield* projectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + interactive: Option.some(true), + }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${API}/v1/organizations`, + `POST ${API}/v1/projects`, + ]); + assertPermissionDrift({ + command: "projects create", + activeFlags: { interactive: true, "db-password": true, region: true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("fails with ProjectsCreateNameEmptyError when the name prompt is blank", () => { const { layer } = setup({ stdinIsTty: true, promptTextResponses: [""] }); return Effect.gen(function* () { @@ -384,7 +437,7 @@ describe("projects create integration", () => { }); it.effect("fails when the interactive organization list request errors", () => { - const { layer } = setup({ + const { layer, api } = setup({ stdinIsTty: true, byMethod: { GET: { status: 500, body: {} }, POST: { status: 201, body: CREATED } }, }); @@ -396,6 +449,10 @@ describe("projects create integration", () => { if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("ProjectsOrgsListUnexpectedStatusError"); } + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${API}/v1/organizations`, + ]); + assertPermissionDrift({ command: "projects create", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -565,6 +622,69 @@ describe("projects create integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("fills the real linked-project cache with the created project", () => { + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "POST" && request.url === `${API}/v1/projects`) { + return jsonResponse(request, 201, CREATED); + } + if (request.method === "GET" && request.url === `${API}/v1/projects/${CREATED.ref}`) { + return jsonResponse(request, 200, { + ...CREATED, + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, + }); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); + const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out: mockOutput({ format: "text" }), + api, + cliSettings, + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }), + Layer.succeed(ExperimentalFlag, false), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* projectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + orgId: Option.some("acme"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + }); + + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `POST ${API}/v1/projects`, + `GET ${API}/v1/projects/${CREATED.ref}`, + ]); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: CREATED.ref, + }); + assertPermissionDrift({ + command: "projects create", + activeFlags: { "org-id": true, "db-password": true, region: true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + it.effect("flushes telemetry but skips cache when creation fails", () => { const { layer, telemetry, cache } = setup({ network: "fail" }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/projects/delete/delete.command.ts b/apps/cli/src/commands/projects/delete/delete.command.ts index 0159ce28b2..814d401b5b 100644 --- a/apps/cli/src/commands/projects/delete/delete.command.ts +++ b/apps/cli/src/commands/projects/delete/delete.command.ts @@ -1,6 +1,9 @@ import { Layer } from "effect"; import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; @@ -24,6 +27,22 @@ export const projectsDeleteCommand = Command.make("delete", config).pipe( description: "Delete a project by ref", }, ]), + withPermissions( + compose( + { + operations: [ + { + operationId: "v1-list-all-projects", + kind: "required", + context: + "no ref argument (or an empty one) and an interactive terminal (stdin TTY and text output); SUPABASE_PROJECT_ID and supabase/.temp/project-ref are ignored", + }, + { operationId: "v1-delete-a-project", kind: "required" }, + ], + }, + linkedProjectCache, + ), + ), Command.withHandler((flags) => projectsDelete(flags).pipe( withCommandTelemetry({ flags, safeFlags: [] }), diff --git a/apps/cli/src/commands/projects/delete/delete.integration.test.ts b/apps/cli/src/commands/projects/delete/delete.integration.test.ts index 4558559c30..2fce7458a4 100644 --- a/apps/cli/src/commands/projects/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/projects/delete/delete.integration.test.ts @@ -8,6 +8,7 @@ import { type HttpMethod, VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, @@ -15,10 +16,13 @@ import { useTempWorkdir, withConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { YesFlag } from "../../../command-internal/global-flags.ts"; import { projectsDelete } from "./delete.handler.ts"; const OTHER_REF = "qrstuvwxyzabcdefghij"; +const API = "https://api.supabase.com"; const DELETED = { id: 1, ref: VALID_REF, name: "alpha" }; @@ -151,8 +155,65 @@ describe("projects delete integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("deletes the project chosen in the picker, then looks it up for the real cache", () => { + const out = mockOutput({ + format: "text", + promptSelectResponses: [VALID_REF], + promptConfirmResponses: [true], + }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "GET" && request.url === `${API}/v1/projects`) { + return jsonResponse(request, 200, [SAMPLE_PROJECT]); + } + if (request.method === "GET" && request.url === `${API}/v1/projects/${VALID_REF}`) { + return jsonResponse(request, 404, { message: "not found" }); + } + if (request.method === "DELETE" && request.url === `${API}/v1/projects/${VALID_REF}`) { + return jsonResponse(request, 200, DELETED); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + stdin: mockStdin(true), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }), + Layer.succeed(YesFlag, false), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* projectsDelete({ ref: Option.none() }); + + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${API}/v1/projects`, + `DELETE ${API}/v1/projects/${VALID_REF}`, + `GET ${API}/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain("Deleted project: alpha"); + expect(yield* fs.exists(cachePath)).toBe(false); + assertPermissionDrift({ command: "projects delete", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + it.effect("fails when no ref is given on a non-TTY", () => { - const { layer, cache } = setup({ stdinIsTty: false }); + const { layer, cache, api } = setup({ stdinIsTty: false }); return Effect.gen(function* () { const exit = yield* Effect.exit(projectsDelete({ ref: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); @@ -160,6 +221,7 @@ describe("projects delete integration", () => { expect(Cause.pretty(exit.cause)).toContain("ProjectsDeleteRefRequiredError"); } expect(cache.cached).toBe(false); + expect(api.requests).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -186,6 +248,7 @@ describe("projects delete integration", () => { expect(out.stderrText).toContain("Do you want to delete project "); expect(out.stderrText).toContain("? This action is irreversible. [y/N] y\n"); expect(hasMethod(api, "DELETE")).toBe(true); + assertPermissionDrift({ command: "projects delete", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)), ); }); diff --git a/apps/cli/src/commands/projects/list/list.command.ts b/apps/cli/src/commands/projects/list/list.command.ts index c8395b618c..d9169ef0a5 100644 --- a/apps/cli/src/commands/projects/list/list.command.ts +++ b/apps/cli/src/commands/projects/list/list.command.ts @@ -1,6 +1,9 @@ import { Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withContext } from "../../../command-internal/command-permissions/model.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -22,6 +25,15 @@ export const projectsListCommand = Command.make("list", config).pipe( description: "Machine-readable JSON output", }, ]), + withPermissions( + compose( + { operations: [{ operationId: "v1-list-all-projects", kind: "required" }] }, + withContext( + linkedProjectCache, + "a linked ref from SUPABASE_PROJECT_ID or supabase/.temp/project-ref", + ), + ), + ), Command.withHandler((flags) => projectsList(flags).pipe(withCommandTelemetry({ flags, safeFlags: [] }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/projects/list/list.integration.test.ts b/apps/cli/src/commands/projects/list/list.integration.test.ts index d76f2f94ff..b7f374423e 100644 --- a/apps/cli/src/commands/projects/list/list.integration.test.ts +++ b/apps/cli/src/commands/projects/list/list.integration.test.ts @@ -6,12 +6,15 @@ import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { projectsList } from "./list.handler.ts"; type Projects = typeof V1ListAllProjectsOutput.Type; @@ -51,6 +54,9 @@ const PARENT_PROJECT: Projects[number] = { }; const tempRoot = useTempWorkdir("supabase-projects-list-int-"); +const API = "https://api.supabase.com"; + +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); const LinkedProjectsPayload = Schema.Struct({ projects: Schema.Array(Schema.Struct({ id: Schema.String, linked: Schema.Boolean })), @@ -140,9 +146,10 @@ function setupTracked(opts: SetupOpts = {}) { describe("projects list integration", () => { it.effect("renders a Glamour table with all six columns in text mode", () => { - const { layer, out } = setup({ response: [SAMPLE_PROJECT, OTHER_PROJECT] }); + const { layer, out, api } = setup({ response: [SAMPLE_PROJECT, OTHER_PROJECT] }); return Effect.gen(function* () { yield* projectsList({}); + assertPermissionDrift({ command: "projects list", requests: api.requests, exact: true }); expect(out.stdoutText).toContain("LINKED"); expect(out.stdoutText).toContain("ORG ID"); expect(out.stdoutText).toContain("REFERENCE ID"); @@ -386,4 +393,48 @@ describe("projects list integration", () => { expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); + + it.effect("fills the real linked-project cache for a ref saved in supabase/.temp", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "GET" && request.url === `${API}/v1/projects`) { + return jsonResponse(request, 200, [SAMPLE_PROJECT]); + } + if (request.method === "GET" && request.url === `${API}/v1/projects/${VALID_REF}`) { + return jsonResponse(request, 200, SAMPLE_PROJECT); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* writeProjectRefFile(tempRoot.current, VALID_REF); + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* projectsList({}); + + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${API}/v1/projects`, + `GET ${API}/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain("●"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "projects list", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/cli/src/commands/services/services.command.ts b/apps/cli/src/commands/services/services.command.ts index 7a2d664905..7b23a7e320 100644 --- a/apps/cli/src/commands/services/services.command.ts +++ b/apps/cli/src/commands/services/services.command.ts @@ -1,4 +1,8 @@ import { Command } from "effect/cli"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withContext } from "../../command-internal/command-permissions/model.ts"; +import { linkedServiceVersions } from "../../shared/services/services.shared.ts"; +import { linkedProjectCache } from "../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import type * as CliCommand from "effect/cli/Command"; @@ -11,6 +15,18 @@ export type ServicesFlags = CliCommand.Command.Config.Infer; export const servicesCommand = Command.make("services", config).pipe( Command.withDescription("Show versions of all Supabase services."), Command.withShortDescription("Show versions of all Supabase services"), + withPermissions( + compose( + withContext( + linkedServiceVersions, + "a 20-letter linked project ref (SUPABASE_PROJECT_ID, else supabase/.temp/project-ref) and an access token", + ), + withContext( + linkedProjectCache, + "a linked project ref (SUPABASE_PROJECT_ID, else supabase/.temp/project-ref; not format-checked)", + ), + ), + ), Command.withHandler((flags) => services(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/services/services.integration.test.ts b/apps/cli/src/commands/services/services.integration.test.ts index 1f713996e8..ba86633639 100644 --- a/apps/cli/src/commands/services/services.integration.test.ts +++ b/apps/cli/src/commands/services/services.integration.test.ts @@ -32,6 +32,7 @@ import { processEnvLayer, } from "../../../tests/helpers/mocks.ts"; import { mockTelemetryStateTracked, useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; import { postgresImageForDbMajorVersion } from "../../shared/services/services.shared.ts"; import { textCliOutputFormatter } from "../../shared/output/text-formatter.ts"; @@ -559,6 +560,7 @@ major_version = 15 ); yield* writeTempFile(workdir, "project-ref", "abcdefghijklmnopqrst"); + const requests: Array<{ method: string; url: string }> = []; const server = yield* Effect.acquireRelease( Effect.try({ catch: (cause) => @@ -568,6 +570,7 @@ major_version = 15 hostname: "127.0.0.1", port: 0, fetch(request) { + requests.push({ method: request.method, url: request.url }); const url = new URL(request.url); if (url.pathname === "/v1/projects/abcdefghijklmnopqrst") { return Response.json({ @@ -625,6 +628,21 @@ major_version = 15 yield* services({}).pipe(Effect.provide(layer)); + const remoteRequests = requests.splice(0).map(({ method, url }) => ({ + method, + url, + path: `${new URL(url).pathname}${new URL(url).search}`, + })); + expect(remoteRequests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + "GET /v1/projects/abcdefghijklmnopqrst/api-keys?reveal=true", + "GET /v1/projects/abcdefghijklmnopqrst", + ]); + assertPermissionDrift({ + command: "services", + requests: remoteRequests, + apiUrl: server.url.origin, + exact: true, + }); expect(out.stderrText).not.toContain(INVALID_PROJECT_REF_MESSAGE); const rows = yield* decodeServiceRows(out.stdoutText); expect(rows).toContainEqual( diff --git a/apps/cli/src/commands/unlink/unlink.command.ts b/apps/cli/src/commands/unlink/unlink.command.ts index 649ea8371e..40f55e3078 100644 --- a/apps/cli/src/commands/unlink/unlink.command.ts +++ b/apps/cli/src/commands/unlink/unlink.command.ts @@ -6,6 +6,7 @@ import { commandSettingsLayer } from "../../config/command-settings.layer.ts"; import { debugLoggerLayer } from "../../command-internal/debug-logger.layer.ts"; import { telemetryStateLayer } from "../../telemetry/telemetry-state.layer.ts"; import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { unlink } from "./unlink.handler.ts"; @@ -30,6 +31,7 @@ const unlinkRuntimeLayer = Layer.mergeAll( export const unlinkCommand = Command.make("unlink").pipe( Command.withDescription("Unlink a Supabase project."), Command.withShortDescription("Unlink a Supabase project"), + withPermissions({ operations: [] }), Command.withHandler(() => unlink().pipe(withCommandTelemetry(), withJsonErrorHandling)), Command.provide(unlinkRuntimeLayer), ); diff --git a/apps/cli/tests/helpers/permission-pending-commands.ts b/apps/cli/tests/helpers/permission-pending-commands.ts index 4aa439fbe0..ac6de1bb24 100644 --- a/apps/cli/tests/helpers/permission-pending-commands.ts +++ b/apps/cli/tests/helpers/permission-pending-commands.ts @@ -1,7 +1,5 @@ /** Leaf command paths that don't declare their permissions with `withPermissions` yet. */ export const PENDING_COMMANDS: ReadonlyArray = [ - "bootstrap", - "completion bash", "completion fish", "completion powershell", @@ -41,8 +39,6 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "issue docs", "issue feature", - "link", - "login", "logout", @@ -53,22 +49,12 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "network-restrictions get", "network-restrictions update", - "orgs create", - "orgs list", - "postgres-config delete", "postgres-config get", "postgres-config update", - "projects api-keys", - "projects create", - "projects delete", - "projects list", - "pull", - "services", - "ssl-enforcement get", "ssl-enforcement update", @@ -91,8 +77,6 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "telemetry enable", "telemetry status", - "unlink", - "vanity-subdomains activate", "vanity-subdomains check-availability", "vanity-subdomains delete", From 1c019647036cd8fd9ec2f105393862b893e63ccb Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Thu, 15 Oct 2026 08:07:36 +0000 Subject: [PATCH 11/26] ci: shorten the Test workflow e2e critical path (#7159) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to #7129. CLI e2e jobs now set the Test workflow's wall time. - Split the `@supabase/cli-e2e` suite into two shards; it was the single longest job. - The e2e sequencer packs shards by estimated wall time: concurrent files share half the `e2e` project's workers, because they contend for one Docker daemon, and exclusive files add their full duration. - Drop the `actions/cache` + `go build` steps in e2e jobs; `supabase#build` already builds or restores `@supabase/cli-go` through turbo. - The `gen types` e2e readiness probe connects over TCP. The Postgres entrypoint's init server listens only on the Unix socket, so a socket probe could pass before the final server started and the seed then failed mid-restart. - macOS port tests run on Blacksmith macOS runners; GitHub-hosted macOS runs took up to several times as long. Measured over repeated CI runs against develop after #7129: | | develop after #7129 | This PR | Change | | --- | --- | --- | --- | | Test workflow wall time (median) | 3.9 min | 2.75 min | about 30% faster | | Slowest CLI e2e job | 3.3 min | about 2.5 min | about 25% faster | | macOS port tests job | 2.1–8.5 min | 0.7–0.9 min | 3–10x faster | | Files on the Docker-heavy CLI shard | 32 | 10 | | Required check names are unchanged. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/workflows/test.yml | 20 +---- .../src/commands/gen/types/types.e2e.test.ts | 3 + apps/cli/tests/e2e-sequencer.ts | 77 +++++++++++++++---- apps/cli/tests/e2e-sequencer.unit.test.ts | 41 ++++++++-- 4 files changed, 103 insertions(+), 38 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d88906c01b..f2fe2ca454 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -231,7 +231,7 @@ jobs: matrix: # Linux coverage for these files comes from the integration job, which # runs the whole stack integration project. - os: [macos-latest, windows-latest] + os: [blacksmith-6vcpu-macos-latest, windows-latest] steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -381,7 +381,8 @@ jobs: - { suite: cli, shard: 3, shards: 5, name: cli 3/5 } - { suite: cli, shard: 4, shards: 5, name: cli 4/5 } - { suite: cli, shard: 5, shards: 5, name: cli 5/5 } - - { suite: cli-e2e, name: cli-e2e } + - { suite: cli-e2e, shard: 1, shards: 2, name: cli-e2e 1/2 } + - { suite: cli-e2e, shard: 2, shards: 2, name: cli-e2e 2/2 } steps: - name: Checkout uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1.0.0-beta @@ -395,19 +396,6 @@ jobs: sticky-disks: "true" docker-images: "true" - - name: Cache Go CLI binary - id: cache-go-binary - uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 - with: - path: apps/cli-go/supabase-go - key: go-cli-${{ runner.os }}-${{ hashFiles('apps/cli-go/**/*.go', - 'apps/cli-go/go.mod', 'apps/cli-go/go.sum') }} - - - name: Build Go CLI - if: steps.cache-go-binary.outputs.cache-hit != 'true' - run: go build -o supabase-go . - working-directory: apps/cli-go - # The e2e harness invokes `node apps/cli/dist/supabase.js` with # `SUPABASE_CLI_BINARY_OVERRIDE` pointing at the compiled binary in # `apps/cli/dist/`. Build the CLI explicitly before invoking every @@ -424,7 +412,7 @@ jobs: - name: Run cli-e2e end-to-end tests if: matrix.suite == 'cli-e2e' - run: pnpm exec turbo run test:e2e:run --only --filter=@supabase/cli-e2e + run: pnpm exec turbo run test:e2e:run --only --filter=@supabase/cli-e2e -- --shard=${{ matrix.shard }}/${{ matrix.shards }} env: SUPABASE_GO_BINARY: ${{ github.workspace }}/apps/cli-go/supabase-go diff --git a/apps/cli/src/commands/gen/types/types.e2e.test.ts b/apps/cli/src/commands/gen/types/types.e2e.test.ts index 78997f9979..002cf43717 100644 --- a/apps/cli/src/commands/gen/types/types.e2e.test.ts +++ b/apps/cli/src/commands/gen/types/types.e2e.test.ts @@ -167,6 +167,9 @@ const waitForLocalPostgres = Effect.fnUntraced(function* (containerName: string) "PGPASSWORD=postgres", containerName, "psql", + // The entrypoint's init server listens only on the Unix socket, so TCP reaches the final server. + "-h", + "127.0.0.1", "-U", "postgres", "-d", diff --git a/apps/cli/tests/e2e-sequencer.ts b/apps/cli/tests/e2e-sequencer.ts index 0ce50c1f43..aec487fedb 100644 --- a/apps/cli/tests/e2e-sequencer.ts +++ b/apps/cli/tests/e2e-sequencer.ts @@ -4,7 +4,9 @@ import { BaseSequencer, type TestSpecification } from "vitest/node"; /** Bun `--timings` file of measured e2e file durations, refreshed from CI artifacts. */ const TIMINGS_FILE = "tests/e2e-timings.json"; -const E2E_PROJECTS = new Set(["e2e", "e2e-exclusive"]); +const E2E_PROJECT = "e2e"; +const E2E_EXCLUSIVE_PROJECT = "e2e-exclusive"; +const E2E_PROJECTS = new Set([E2E_PROJECT, E2E_EXCLUSIVE_PROJECT]); /** Duration assumed for every file while no timing has been measured yet. */ export const DEFAULT_DURATION_MS = 30_000; @@ -28,31 +30,70 @@ function compareKeys(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } +/** An e2e file to shard; exclusive files run one at a time after the concurrent ones. */ +export interface ShardFile { + readonly key: string; + readonly exclusive: boolean; +} + +interface ShardLoad { + readonly longestConcurrent: number; + readonly concurrentTotal: number; + readonly exclusiveTotal: number; +} + +function withFile(load: ShardLoad, duration: number, exclusive: boolean): ShardLoad { + return exclusive + ? { ...load, exclusiveTotal: load.exclusiveTotal + duration } + : { + ...load, + longestConcurrent: Math.max(load.longestConcurrent, duration), + concurrentTotal: load.concurrentTotal + duration, + }; +} + +function estimatedWall(load: ShardLoad, workers: number): number { + return Math.max(load.longestConcurrent, load.concurrentTotal / workers) + load.exclusiveTotal; +} + /** - * Splits keys into `count` shards by measured duration, longest first into the emptiest shard. - * The result depends only on the set of keys, so every shard process derives the same partition. + * Splits files into `count` shards by estimated wall time, longest first into the shard whose + * estimate stays lowest, the less loaded one on ties. Concurrent files share `workers` slots; + * exclusive files add their full duration. + * The result depends only on the set of files, so every shard process derives the same partition. */ export function packShards( - keys: readonly string[], + files: readonly ShardFile[], timings: Readonly>, count: number, + workers: number, ): string[][] { const fallback = fallbackDuration(timings); - const durations = keys - .map((key) => ({ key, duration: timings[key] ?? fallback })) + const durations = files + .map((file) => ({ ...file, duration: timings[file.key] ?? fallback })) .sort((a, b) => b.duration - a.duration || compareKeys(a.key, b.key)); - const shards = Array.from({ length: count }, () => ({ load: 0, keys: new Array() })); - for (const { key, duration } of durations) { - let target = shards[0]; + const empty: ShardLoad = { longestConcurrent: 0, concurrentTotal: 0, exclusiveTotal: 0 }; + const shards = Array.from({ length: count }, () => ({ keys: new Array(), load: empty })); + for (const { key, exclusive, duration } of durations) { + let target: + | { shard: (typeof shards)[number]; load: ShardLoad; wall: number; before: number } + | undefined; for (const shard of shards) { - if (target === undefined || shard.load < target.load) { - target = shard; + const load = withFile(shard.load, duration, exclusive); + const wall = estimatedWall(load, workers); + const before = estimatedWall(shard.load, workers); + if ( + target === undefined || + wall < target.wall || + (wall === target.wall && before < target.before) + ) { + target = { shard, load, wall, before }; } } if (target !== undefined) { - target.load += duration; - target.keys.push(key); + target.shard.load = target.load; + target.shard.keys.push(key); } } return shards.map((shard) => shard.keys); @@ -109,7 +150,15 @@ export class E2eSequencer extends BaseSequencer { const byKey = new Map( files.map((spec) => [relative(root, spec.moduleId).replaceAll("\\", "/"), spec]), ); - const assigned = packShards([...byKey.keys()], timings, shard.count)[shard.index - 1] ?? []; + const shardFiles = [...byKey].map(([key, spec]) => ({ + key, + exclusive: spec.project.name === E2E_EXCLUSIVE_PROJECT, + })); + const maxWorkers = + files.find((spec) => spec.project.name === E2E_PROJECT)?.project.config.maxWorkers ?? 1; + // Concurrent files share one Docker daemon and slow each other, so pack against half the slots. + const workers = Math.max(1, Math.floor(maxWorkers / 2)); + const assigned = packShards(shardFiles, timings, shard.count, workers)[shard.index - 1] ?? []; return assigned.flatMap((key) => { const spec = byKey.get(key); return spec === undefined ? [] : [spec]; diff --git a/apps/cli/tests/e2e-sequencer.unit.test.ts b/apps/cli/tests/e2e-sequencer.unit.test.ts index 1826cab568..d90a7e3813 100644 --- a/apps/cli/tests/e2e-sequencer.unit.test.ts +++ b/apps/cli/tests/e2e-sequencer.unit.test.ts @@ -9,11 +9,13 @@ const files = ["src/a.e2e.test.ts", "src/b.e2e.test.ts", "src/c.e2e.test.ts", "s const sortedShards = (shards: readonly (readonly string[])[]) => shards.map((shard) => [...shard].sort()); +const concurrent = (keys: readonly string[]) => keys.map((key) => ({ key, exclusive: false })); + describe("packShards", () => { test.each([1, files.length, files.length + 3])( "partitions every file into exactly one of %i shards", (count) => { - const shards = packShards(files, { "src/a.e2e.test.ts": 100 }, count); + const shards = packShards(concurrent(files), { "src/a.e2e.test.ts": 100 }, count, 1); expect(shards).toHaveLength(count); expect(shards.flat().sort()).toEqual([...files].sort()); @@ -21,7 +23,7 @@ describe("packShards", () => { ); test("leaves trailing shards empty when there are more shards than files", () => { - const shards = packShards(["src/a.e2e.test.ts", "src/b.e2e.test.ts"], {}, 4); + const shards = packShards(concurrent(["src/a.e2e.test.ts", "src/b.e2e.test.ts"]), {}, 4, 1); expect(shards).toEqual([["src/a.e2e.test.ts"], ["src/b.e2e.test.ts"], [], []]); }); @@ -33,8 +35,8 @@ describe("packShards", () => { "src/c.e2e.test.ts": 900, }; - const forward = packShards(files, timings, 2); - const reversed = packShards([...files].reverse(), timings, 2); + const forward = packShards(concurrent(files), timings, 2, 1); + const reversed = packShards(concurrent([...files].reverse()), timings, 2, 1); expect(reversed).toEqual(forward); }); @@ -47,7 +49,7 @@ describe("packShards", () => { "src/d.e2e.test.ts": 10, }; - const shards = packShards(files, timings, 2); + const shards = packShards(concurrent(files), timings, 2, 1); expect(sortedShards(shards)).toEqual([ ["src/b.e2e.test.ts"], @@ -55,6 +57,27 @@ describe("packShards", () => { ]); }); + test("packs concurrent files beside a long one while an exclusive file runs alone", () => { + const timings = { + "src/long.e2e.test.ts": 90, + "src/exclusive.e2e.test.ts": 80, + "src/a.e2e.test.ts": 20, + "src/b.e2e.test.ts": 20, + "src/c.e2e.test.ts": 20, + }; + const shardFiles = Object.keys(timings).map((key) => ({ + key, + exclusive: key === "src/exclusive.e2e.test.ts", + })); + + const shards = packShards(shardFiles, timings, 2, 4); + + expect(sortedShards(shards)).toEqual([ + ["src/a.e2e.test.ts", "src/b.e2e.test.ts", "src/c.e2e.test.ts", "src/long.e2e.test.ts"], + ["src/exclusive.e2e.test.ts"], + ]); + }); + test("weights an unmeasured file at the median of the measured ones", () => { const timings = { "src/a.e2e.test.ts": 100, @@ -63,7 +86,7 @@ describe("packShards", () => { }; // d is unmeasured; at the median (300) it ties with b and follows it into the second shard. - const shards = packShards(files, timings, 2); + const shards = packShards(concurrent(files), timings, 2, 1); expect(sortedShards(shards)).toEqual([ ["src/a.e2e.test.ts", "src/c.e2e.test.ts"], @@ -74,13 +97,15 @@ describe("packShards", () => { test("weights every file at the default duration when nothing has been measured", () => { const allDefault = Object.fromEntries(files.map((file) => [file, DEFAULT_DURATION_MS])); - expect(packShards(files, {}, 3)).toEqual(packShards(files, allDefault, 3)); + expect(packShards(concurrent(files), {}, 3, 1)).toEqual( + packShards(concurrent(files), allDefault, 3, 1), + ); }); test("breaks duration ties by key so equal files spread deterministically", () => { const timings = { "src/c.e2e.test.ts": 50, "src/a.e2e.test.ts": 50, "src/b.e2e.test.ts": 50 }; - const shards = packShards(Object.keys(timings), timings, 3); + const shards = packShards(concurrent(Object.keys(timings)), timings, 3, 1); expect(shards).toEqual([["src/a.e2e.test.ts"], ["src/b.e2e.test.ts"], ["src/c.e2e.test.ts"]]); }); From e47b5048d4fb335dab4fb36d2cdf9cdc49752605 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Thu, 15 Oct 2026 09:32:27 +0000 Subject: [PATCH 12/26] fix(cli): mint one login role for db diff --from linked --to linked (#7139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `supabase db diff --from linked --to linked` failed with `password authentication failed for user "cli_login_postgres"` when no database password was available (no `--password`, `SUPABASE_DB_PASSWORD`, or `supabase/.env`). Each linked side called the resolver separately, minting two temporary login roles; the second mint invalidated the first side's credentials. The explicit-mode `linked` endpoint is now resolved once and reused for both sides, so a run issues a single `POST /v1/projects/{ref}/cli/login-role`. Runs with a password configured are unchanged. Also corrects routes in the `SIDE_EFFECTS.md` files for `db diff`, `db pull`, `db dump`, `migration squash`, `inspect db`, and `test db`: temp login role is `POST /cli/login-role`, pooler fallback is `GET /config/database/pooler`, and ban listing is `POST /network-bans/retrieve`. ## Linked issue Supabase maintainer change; no linked issue. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- apps/cli/src/commands/db/diff/diff.handler.ts | 15 ++++++-- .../commands/db/diff/diff.integration.test.ts | 36 +++++++++++++++---- .../commands/migration/squash/SIDE_EFFECTS.md | 15 ++++---- apps/cli/src/commands/test/db/SIDE_EFFECTS.md | 10 +++--- 4 files changed, 55 insertions(+), 21 deletions(-) diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index f486062ee0..e6bf7b1078 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -237,6 +237,9 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { // The first migrations endpoint resolved wins: the engine provisions a single migrations // shadow/catalog, so only refs resolved before that point should influence it. let migrationsToml: DbTomlValues | undefined; + // Each linked resolution mints a temporary login role that invalidates the previous one, + // so the preflight and every `linked` ref share a single connection. + let linkedEndpoint: PgDeltaDatabaseEndpoint | undefined; // The preflight target resolve below validates a changed target flag and is stateful: a // changed `--linked` resolves the project ref and merges `[remotes.]`, so the // explicit `local`/`migrations` refs below see that override. `--local`/`--db-url` load @@ -260,6 +263,12 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { yield* Ref.set(linkedRefForCache, Option.some(preflightRef)); mergedLinkedRef = preflightRef; } + linkedEndpoint = { + kind: "database", + ref: toPostgresURL(preflight.conn), + connection: preflight.conn, + connectOptions: { isLocal: preflight.isLocal, dnsResolver }, + }; } } // Read config once, after the preflight: the `[remotes.]`-merged config @@ -318,6 +327,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { } satisfies PgDeltaDatabaseEndpoint; } case "linked": { + if (linkedEndpoint !== undefined) return linkedEndpoint; const resolved = yield* resolver.resolve({ dbUrl: Option.none(), connType: "linked", @@ -331,12 +341,13 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { mergedLinkedRef = ref2; cfg = yield* readDbToml(fs, path, cliSettings.workdir, ref2); } - return { + linkedEndpoint = { kind: "database", ref: toPostgresURL(resolved.conn), connection: resolved.conn, connectOptions: { isLocal: resolved.isLocal, dnsResolver }, - } satisfies PgDeltaDatabaseEndpoint; + }; + return linkedEndpoint; } case "migrations": // Preserve resolution order: the migrations shadow/catalog config must reflect diff --git a/apps/cli/src/commands/db/diff/diff.integration.test.ts b/apps/cli/src/commands/db/diff/diff.integration.test.ts index df51d52bb1..464e38df92 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -2842,7 +2842,7 @@ describe("db diff permissions through the real resolver and cache", () => { ); }); - it.effect("mints a login role for each side when both --from and --to are linked", () => { + it.effect("mints one login role shared by both sides when --from and --to are linked", () => { const s = setup(tmp.current, { realLayers: true, diffSql: "create table e ();\n" }); const requests: Array = []; return Effect.gen(function* () { @@ -2854,12 +2854,7 @@ describe("db diff permissions through the real resolver and cache", () => { projectRef: Option.some(VALID_REF), }), ).pipe(Effect.provide(s.layer)); - expect(asLines(requests)).toEqual([ - poolerRequest, - loginRoleRequest, - poolerRequest, - loginRoleRequest, - ]); + expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); assertPermissionDrift({ command: "db diff", activeFlags: { from: "linked", to: "linked", "project-ref": VALID_REF }, @@ -2872,4 +2867,31 @@ describe("db diff permissions through the real resolver and cache", () => { provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), ); }); + + it.effect("mints one login role shared by the --linked preflight and both linked sides", () => { + const s = setup(tmp.current, { realLayers: true, diffSql: "create table e ();\n" }); + const requests: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbDiff( + flags({ + from: Option.some("linked"), + to: Option.some("linked"), + linked: Option.some(true), + projectRef: Option.some(VALID_REF), + }), + ).pipe(Effect.provide(s.layer)); + expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); + assertPermissionDrift({ + command: "db diff", + activeFlags: { linked: true, from: "linked", to: "linked", "project-ref": VALID_REF }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); }); diff --git a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md index 619d236be6..6755704f8d 100644 --- a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md @@ -74,13 +74,14 @@ live shadow available. ## API Routes -| Method | Path | Auth | Purpose | -| ----------- | ----------------------------------------------------------------------------- | ------ | ---------------------------------------------------------- | -| — | — | — | local target: none | -| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | `--linked`: temp login role when no password | -| GET | `/v1/projects/{ref}/config/database/pooler` | Bearer | `--linked`: IPv4 pooler fallback (IPv6-only network) | -| POST/DELETE | `/v1/projects/{ref}/network-bans/retrieve`, `/v1/projects/{ref}/network-bans` | Bearer | `--linked`: unban during pooler login retry | -| GET | `/v1/projects/{ref}` | Bearer | `--linked`: linked-project cache (post-run, unconditional) | +| Method | Path | Auth | Purpose | +| ------ | ------------------------------------------- | ------ | ---------------------------------------------------------- | +| — | — | — | local target: none | +| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | `--linked`: temp login role when no password | +| GET | `/v1/projects/{ref}/config/database/pooler` | Bearer | `--linked`: IPv4 pooler fallback (IPv6-only network) | +| POST | `/v1/projects/{ref}/network-bans/retrieve` | Bearer | `--linked`: list bans during pooler login retry | +| DELETE | `/v1/projects/{ref}/network-bans` | Bearer | `--linked`: unban during pooler login retry | +| GET | `/v1/projects/{ref}` | Bearer | `--linked`: linked-project cache (post-run, unconditional) | ## Environment Variables diff --git a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md index 056307b146..3fbdd04e34 100644 --- a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md @@ -41,11 +41,11 @@ Managed stack `--local` prove runs the catalog `pg_prove` tool through the ownin ## API Routes (`--linked` only) -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------- | ------------ | ----------------------------------------- | --------------------------- | -| POST | `/v1/projects/{ref}/cli/login-role` | access token | `{ read_only: false }` | `{ role, password }` | -| GET | `/v1/projects/{ref}/network-bans` | access token | — | `{ banned_ipv4_addresses }` | -| DELETE | `/v1/projects/{ref}/network-bans` | access token | `{ ipv4_addresses, requester_ip: false }` | — | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------ | ------------ | ----------------------------------------- | --------------------------- | +| POST | `/v1/projects/{ref}/cli/login-role` | access token | `{ read_only: false }` | `{ role, password }` | +| POST | `/v1/projects/{ref}/network-bans/retrieve` | access token | — | `{ banned_ipv4_addresses }` | +| DELETE | `/v1/projects/{ref}/network-bans` | access token | `{ ipv4_addresses, requester_ip: false }` | — | ## Environment Variables From 97b1e4baa3eeb385e5611f46d25f1d21c1c4dabe Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Thu, 15 Oct 2026 09:50:53 +0000 Subject: [PATCH 13/26] chore(cli): map project network command permissions (#7171) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Declares the Management API permissions for the project network commands: `vanity-subdomains` (get, check-availability, activate, delete), `ssl-enforcement` (get, update), `encryption` (get-root-key, update-root-key), `network-bans` (get, remove), `network-restrictions` (get, update) and `postgres-config` (get, update, delete). Each leaf composes the project-ref picker and the linked-project cache. **Shared change:** `postgresConfigRead` and `postgresConfigWrite` are new blocks in `postgres-config.shared.ts`, next to the helpers that issue the calls (`postgres-config` reaches the API through `executeRaw` on the typed client, so the drift matcher resolves them). `get` uses the read block, `update` the read block (only when `--replace-existing-overrides` is false) plus the write block, and `delete` both. **Conditions declared** - `network-restrictions update`: `is` on `--append` selects the POST `/apply` or the PATCH. - `ssl-enforcement update`: the PUT is declared once per switch (`--enable-db-ssl-enforcement`, `--disable-db-ssl-enforcement`), each with `is: true`. - `vanity-subdomains activate` and `check-availability`: the call is declared for `--desired-subdomain` present and for `oneOf: [""]`, because the handlers send an empty value to the API. Both also compose `upgradeSuggestion`. **Findings** - `v1-get-pgsodium-config` is a GET that needs `project_admin_write`, and `check-availability` is a POST that needs `vanity_subdomain_write`. - `postgres-config` makes no restart call and has no delete endpoint: `delete` is a GET followed by a PUT, and `--no-restart` only shapes the PUT body. - `network-bans remove` fills the linked-project cache even when an IP is invalid, while `network-restrictions update` and `ssl-enforcement update` validate before resolving the ref and fill nothing. `postgres-config update` only checks that each `--config` value is a `key=value` pair before resolving; an unknown key is rejected after the ref resolves and the GET, so the cache is still filled. `encryption` resolves the ref outside the cache-fill scope. - The SIDE_EFFECTS docs pointed the cache at `~/.supabase//`, described a `.supabase/config.json` that nothing reads, and omitted the picker and cache-fill routes. They now match the code. **Not covered** - The experimental gate is not modelled; declarations describe a run whose gate is open. - `ssl-enforcement update` over-declares when both switches are true, a run that fails before any call. - Picker, cache and upgrade-lookup entries carry a context, so they are checked by request sequence rather than by the exact drift check. Dropping `linkedProjectCache` from vanity `activate` or `check-availability` fails no test, because `upgradeSuggestion` also declares `v1-get-project`. - `--switch=false` and `--desired-subdomain=` are exercised with parsed values, not through the flag parser. - No new entries in `OPERATIONS_WITHOUT_FGA`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 --- .../src/commands/encryption/SIDE_EFFECTS.md | 39 +-- .../get-root-key/get-root-key.command.ts | 13 + .../get-root-key.integration.test.ts | 67 +++++- .../update-root-key.command.ts | 13 + .../update-root-key.integration.test.ts | 68 +++++- .../commands/network-bans/get/SIDE_EFFECTS.md | 39 +-- .../commands/network-bans/get/get.command.ts | 13 + .../network-bans/get/get.integration.test.ts | 89 ++++++- .../network-bans/remove/SIDE_EFFECTS.md | 41 ++-- .../network-bans/remove/remove.command.ts | 14 ++ .../remove/remove.integration.test.ts | 68 +++++- .../network-restrictions/get/SIDE_EFFECTS.md | 40 +-- .../network-restrictions/get/get.command.ts | 13 + .../get/get.integration.test.ts | 67 +++++- .../update/SIDE_EFFECTS.md | 42 ++-- .../update/update.command.ts | 25 ++ .../update/update.integration.test.ts | 126 +++++++++- .../postgres-config/delete/SIDE_EFFECTS.md | 43 ++-- .../postgres-config/delete/delete.command.ts | 14 ++ .../postgres-config/get/SIDE_EFFECTS.md | 41 ++-- .../postgres-config/get/get.command.ts | 8 + .../postgres-config.integration.test.ts | 186 +++++++++++++- .../postgres-config/postgres-config.shared.ts | 11 + .../postgres-config/update/SIDE_EFFECTS.md | 45 ++-- .../postgres-config/update/update.command.ts | 14 ++ .../ssl-enforcement/get/SIDE_EFFECTS.md | 40 +-- .../ssl-enforcement/get/get.command.ts | 13 + .../get/get.integration.test.ts | 64 ++++- .../ssl-enforcement/update/SIDE_EFFECTS.md | 37 +-- .../ssl-enforcement/update/update.command.ts | 24 ++ .../update/update.integration.test.ts | 91 ++++++- .../activate/SIDE_EFFECTS.md | 47 ++-- .../activate/activate.command.ts | 26 ++ .../check-availability/SIDE_EFFECTS.md | 47 ++-- .../check-availability.command.ts | 26 ++ .../vanity-subdomains/delete/SIDE_EFFECTS.md | 43 ++-- .../delete/delete.command.ts | 13 + .../vanity-subdomains/get/SIDE_EFFECTS.md | 43 ++-- .../vanity-subdomains/get/get.command.ts | 13 + .../vanity-subdomains.integration.test.ts | 227 +++++++++++++++++- .../helpers/permission-pending-commands.ts | 21 -- 41 files changed, 1605 insertions(+), 309 deletions(-) diff --git a/apps/cli/src/commands/encryption/SIDE_EFFECTS.md b/apps/cli/src/commands/encryption/SIDE_EFFECTS.md index 2c021c7641..3b50dcedad 100644 --- a/apps/cli/src/commands/encryption/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/encryption/SIDE_EFFECTS.md @@ -6,35 +6,38 @@ additionally reads the new key from stdin. ## Files Read -| Path | Format | When | -| ------------------------------------------------ | ------------------------- | ------------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `~/.supabase//linked-project.json` | JSON | when `--project-ref` / `SUPABASE_PROJECT_ID` unset, to resolve linked ref | -| stdin | raw bytes / masked TTY | `update-root-key` only — masked TTY input or piped bytes (the key) | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | +| stdin | raw bytes / masked TTY | `update-root-key` only — masked TTY input or piped bytes (the key) | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | PersistentPostRun, after the project ref resolves | -| `~/.supabase/telemetry.json` | JSON | PersistentPostRun, on success or failure | +| Path | Format | When | +| ---------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the project ref resolves (and, for `update-root-key`, after the key is read), on success or failure of the API call — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | PersistentPostRun, on success or failure | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------- | ------------ | ------------ | ---------------------- | -| `GET` | `/v1/projects/{ref}/pgsodium` | Bearer token | none | `{root_key}` | -| `PUT` | `/v1/projects/{ref}/pgsodium` | Bearer token | `{root_key}` | `{root_key}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/pgsodium` | Bearer token | none | `{root_key}` | +| `PUT` | `/v1/projects/{ref}/pgsodium` | Bearer token | `{root_key}` | `{root_key}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | `get-root-key` calls `GET`; `update-root-key` calls `PUT`. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROJECT_ID` | project ref (fallback when `--project-ref` unset) | no (falls back to linked-project file → prompt) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | project ref (fallback when `--project-ref` unset) | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes diff --git a/apps/cli/src/commands/encryption/get-root-key/get-root-key.command.ts b/apps/cli/src/commands/encryption/get-root-key/get-root-key.command.ts index 43c739540f..1456321a96 100644 --- a/apps/cli/src/commands/encryption/get-root-key/get-root-key.command.ts +++ b/apps/cli/src/commands/encryption/get-root-key/get-root-key.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -18,6 +22,15 @@ export type EncryptionGetRootKeyFlags = CliCommand.Command.Config.Infer encryptionGetRootKey(flags).pipe( // `--project-ref` is not telemetry-safe for encryption (no `markFlagTelemetrySafe`). diff --git a/apps/cli/src/commands/encryption/get-root-key/get-root-key.integration.test.ts b/apps/cli/src/commands/encryption/get-root-key/get-root-key.integration.test.ts index 41b44545d5..2d5c8731d4 100644 --- a/apps/cli/src/commands/encryption/get-root-key/get-root-key.integration.test.ts +++ b/apps/cli/src/commands/encryption/get-root-key/get-root-key.integration.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, + jsonResponse, VALID_REF, mockCommandSettings, mockLinkedProjectCacheTracked, @@ -11,8 +12,15 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { encryptionGetRootKey } from "./get-root-key.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const ROOT_KEY_RESPONSE = { root_key: "abc123rootkey" }; interface SetupOpts { @@ -83,6 +91,61 @@ describe("encryption get-root-key integration", () => { return Effect.gen(function* () { yield* encryptionGetRootKey({ projectRef: Option.some(flagRef) }); expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/pgsodium`); + assertPermissionDrift({ + command: "encryption get-root-key", + activeFlags: { "project-ref": true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists projects for the picker and fills the real linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + const path = new URL(request.url).pathname; + if (path === "/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (path === `/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if (path === `/v1/projects/${VALID_REF}/pgsodium`) { + return Effect.succeed(jsonResponse(request, 200, ROOT_KEY_RESPONSE)); + } + return Effect.succeed(jsonResponse(request, 404, { message: "unexpected request" })); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* encryptionGetRootKey(baseFlags); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/pgsodium`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toBe("abc123rootkey\n"); + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "encryption get-root-key", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/encryption/update-root-key/update-root-key.command.ts b/apps/cli/src/commands/encryption/update-root-key/update-root-key.command.ts index 38e38f8556..48771f6ea9 100644 --- a/apps/cli/src/commands/encryption/update-root-key/update-root-key.command.ts +++ b/apps/cli/src/commands/encryption/update-root-key/update-root-key.command.ts @@ -2,6 +2,10 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { ttyLayer } from "../../../shared/runtime/tty.layer.ts"; @@ -29,6 +33,15 @@ const updateRuntime = Layer.mergeAll( export const encryptionUpdateRootKeyCommand = Command.make("update-root-key", config).pipe( Command.withDescription("Update root encryption key of a Supabase project"), Command.withShortDescription("Update the root encryption key"), + withPermissions( + compose( + { + operations: [{ operationId: "v1-update-pgsodium-config", kind: "required" }], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => encryptionUpdateRootKey(flags).pipe( // `--project-ref` is not telemetry-safe for encryption (no `markFlagTelemetrySafe`). diff --git a/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts b/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts index 5d3dbeb3ca..3b2cc9b5fc 100644 --- a/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts +++ b/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema } from "effect"; -import { mockOutput, mockStdin } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, + jsonResponse, VALID_REF, mockCommandSettings, mockLinkedProjectCacheTracked, @@ -11,8 +12,15 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { encryptionUpdateRootKey } from "./update-root-key.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const ROOT_KEY_RESPONSE = { root_key: "new-key" }; interface SetupOpts { @@ -71,6 +79,62 @@ describe("encryption update-root-key integration", () => { expect(out.stderrText).toContain("Finished "); expect(out.stderrText).toContain("supabase root-key update"); expect(out.stdoutText).toBe("\n"); + assertPermissionDrift({ + command: "encryption update-root-key", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists projects for the picker and fills the real linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + const path = new URL(request.url).pathname; + if (path === "/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (path === `/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if (path === `/v1/projects/${VALID_REF}/pgsodium`) { + return Effect.succeed(jsonResponse(request, 200, ROOT_KEY_RESPONSE)); + } + return Effect.succeed(jsonResponse(request, 404, { message: "unexpected request" })); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + stdin: mockStdin(false, "new-key"), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* encryptionUpdateRootKey(baseFlags); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `PUT https://api.supabase.com/v1/projects/${VALID_REF}/pgsodium`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(api.requests[1]?.body).toEqual({ root_key: "new-key" }); + expect(out.stderrText).toContain("Finished "); + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "encryption update-root-key", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md b/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md index e8e1f972ac..7d7352c82c 100644 --- a/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md @@ -2,34 +2,37 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------ | ------------ | ------------ | ------------------------- | -| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `{banned_ipv4_addresses}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------ | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `{banned_ipv4_addresses}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | The Management API exposes this read operation as `POST .../network-bans/retrieve` (not `GET`) — see `V1ListAllNetworkBans` in `packages/api/src/generated/contracts.ts`. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -77,7 +80,7 @@ One `result` event whose `data` is the full response object. ## Notes - The `--output` flag wins over `--output-format` when both are provided. -- `linked-project.json` is written **after** the project ref is resolved, regardless of whether the subsequent API call succeeds. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is open), even when the API call fails, but only on a cache miss with a token when `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed gate writes nothing. - `telemetry.json` is written on every invocation, including failures, but only once the `--experimental` gate is open. - Network bans are temporary blocks on IPs with abusive traffic patterns (e.g. multiple failed auth attempts). - `network-bans` is an experimental command: `get` requires `--experimental` (or diff --git a/apps/cli/src/commands/network-bans/get/get.command.ts b/apps/cli/src/commands/network-bans/get/get.command.ts index 33f268ff60..9ca0064702 100644 --- a/apps/cli/src/commands/network-bans/get/get.command.ts +++ b/apps/cli/src/commands/network-bans/get/get.command.ts @@ -2,6 +2,10 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/go-output-flag.ts"; @@ -24,6 +28,15 @@ export type NetworkBansGetFlags = CliCommand.Command.Config.Infer export const networkBansGetCommand = Command.make("get", config).pipe( Command.withDescription("Get the current network bans."), Command.withShortDescription("Get the current network bans"), + withPermissions( + compose( + { + operations: [{ operationId: "v1-list-all-network-bans", kind: "required" }], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => Effect.gen(function* () { // Validate the -o value before the --experimental gate, so an invalid value is diff --git a/apps/cli/src/commands/network-bans/get/get.integration.test.ts b/apps/cli/src/commands/network-bans/get/get.integration.test.ts index a565ac024e..12f91c4fdf 100644 --- a/apps/cli/src/commands/network-bans/get/get.integration.test.ts +++ b/apps/cli/src/commands/network-bans/get/get.integration.test.ts @@ -1,20 +1,28 @@ import { type V1ListAllNetworkBansOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { networkBansGet } from "./get.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const SAMPLE_BANS: typeof V1ListAllNetworkBansOutput.Type = { banned_ipv4_addresses: ["192.168.0.1", "192.168.0.2"], }; @@ -185,6 +193,83 @@ describe("network-bans get integration", () => { expect(api.requests).toHaveLength(1); expect(api.requests[0]?.method).toBe("POST"); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/network-bans/retrieve`); + assertPermissionDrift({ command: "network-bans get", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the picker's projects, the bans and fills the real linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, 201, SAMPLE_BANS)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* networkBansGet({ projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `POST https://api.supabase.com/v1/projects/${VALID_REF}/network-bans/retrieve`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toBe('[\n "192.168.0.1",\n "192.168.0.2"\n]\n'); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "network-bans get", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("ignores an empty --project-ref and resolves the saved ref without the picker", () => { + const out = mockOutput(); + const api = mockCommandPlatformApi({ response: { status: 201, body: SAMPLE_BANS } }); + const layer = buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current, projectId: Option.none() }), + tty: mockTty({ stdinIsTty: true }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${tempRoot.current}/supabase/.temp`, { recursive: true }); + yield* fs.writeFileString(`${tempRoot.current}/supabase/.temp/project-ref`, VALID_REF); + yield* networkBansGet({ projectRef: Option.some("") }); + expect(out.promptSelectCalls).toHaveLength(0); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `POST https://api.supabase.com/v1/projects/${VALID_REF}/network-bans/retrieve`, + ]); + assertPermissionDrift({ + command: "network-bans get", + activeFlags: { "project-ref": "" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md b/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md index e935a6cf8b..d25c1cb75e 100644 --- a/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md @@ -2,34 +2,37 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed or if flag parsing fails (malformed `--db-unban-ip` CSV). | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed or if flag parsing fails (malformed `--db-unban-ip` CSV). | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | --------------------------------- | ------------ | --------------------------------------------------- | ---------------------- | -| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ipv4_addresses: string[], requester_ip: boolean}` | none | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | --------------------------------- | ------------ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ipv4_addresses: string[], requester_ip: boolean}` | none | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | `requester_ip` is `true` when no `--db-unban-ip` flags are passed (self-unban mode) and `false` otherwise. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -77,8 +80,8 @@ One `result` event on success when the `--output` flag is unset. - The `--output` flag wins over `--output-format` when both are provided. - Requires `--db-unban-ip` flag to specify IP(s) to unban (repeatable). When omitted, the caller's own IP is unbanned (`requester_ip: true`). -- Requires `--project-ref` or a linked project (`.supabase/config.json`). -- `linked-project.json` is written **after** the project ref is resolved, regardless of whether the subsequent API call succeeds. +- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`), or, on an interactive terminal, the project picker. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is open), even when the API call fails, but only on a cache miss with a token when `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed gate writes nothing. - `telemetry.json` is written on every invocation that reaches the handler, including failures, but only once the `--experimental` gate is open. A malformed `--db-unban-ip` CSV value (e.g. an unterminated quote) fails during flag parsing — before the gate, the handler, both file writes, and the `cli_command_executed` event — even when `--experimental` is passed. - `network-bans` is an experimental command: `remove` requires `--experimental` (or `SUPABASE_EXPERIMENTAL`), checked before the login check. diff --git a/apps/cli/src/commands/network-bans/remove/remove.command.ts b/apps/cli/src/commands/network-bans/remove/remove.command.ts index 8eea12f836..cce5d16728 100644 --- a/apps/cli/src/commands/network-bans/remove/remove.command.ts +++ b/apps/cli/src/commands/network-bans/remove/remove.command.ts @@ -2,6 +2,10 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/go-output-flag.ts"; @@ -36,6 +40,16 @@ export type NetworkBansRemoveFlags = CliCommand.Command.Config.Infer Effect.gen(function* () { // Validate the -o value before the --experimental gate, so an invalid value is diff --git a/apps/cli/src/commands/network-bans/remove/remove.integration.test.ts b/apps/cli/src/commands/network-bans/remove/remove.integration.test.ts index 349c75c892..85c3ad7737 100644 --- a/apps/cli/src/commands/network-bans/remove/remove.integration.test.ts +++ b/apps/cli/src/commands/network-bans/remove/remove.integration.test.ts @@ -1,21 +1,29 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { networkBansRemoveDbUnbanIpFlag } from "./remove.command.ts"; import { networkBansRemove } from "./remove.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + // Runs the real --db-unban-ip flag pipeline so these scenarios cover raw CLI values // through to the request body. const parseDbUnbanIp = (rawValues: ReadonlyArray) => @@ -81,6 +89,57 @@ describe("network-bans remove integration", () => { expect(api.requests[0]?.method).toBe("DELETE"); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/network-bans`); expect(api.requests[0]?.body).toEqual({ ipv4_addresses: [], requester_ip: true }); + assertPermissionDrift({ + command: "network-bans remove", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the picker's projects, removes the bans and fills the real cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, 200, null)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* networkBansRemove({ projectRef: Option.none(), dbUnbanIp: [] }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/network-bans`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toBe("Successfully removed network bans.\n"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "network-bans remove", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -95,6 +154,11 @@ describe("network-bans remove integration", () => { ipv4_addresses: ["12.3.4.5", "2001:db8:abcd:0012::0"], requester_ip: false, }); + assertPermissionDrift({ + command: "network-bans remove", + activeFlags: { "db-unban-ip": true }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md b/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md index c671ea8eda..fa8b9125c0 100644 --- a/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md @@ -2,32 +2,35 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------------- | ------------ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/network-restrictions` | Bearer token | none | `{ config: { dbAllowedCidrs?: string[], dbAllowedCidrsV6?: string[] }, status: "stored" \| "applied" }` (see `V1GetNetworkRestrictionsOutput` in `packages/api`) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/network-restrictions` | Bearer token | none | `{ config: { dbAllowedCidrs?: string[], dbAllowedCidrsV6?: string[] }, status: "stored" \| "applied" }` (see `V1GetNetworkRestrictionsOutput` in `packages/api`) | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -92,7 +95,6 @@ One `result` event whose `data` is the full response object. - The `--output` flag wins over `--output-format` when both are provided (see `--output` above). -- `linked-project.json` is written **after** the project ref is resolved, regardless of - whether the subsequent API call succeeds. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is open), even when the API call fails, but only on a cache miss with a token when `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed gate writes nothing. - `telemetry.json` is written on every invocation past the `--experimental` gate, including failures. A closed gate writes nothing. diff --git a/apps/cli/src/commands/network-restrictions/get/get.command.ts b/apps/cli/src/commands/network-restrictions/get/get.command.ts index a4dc736354..5e6201d8f4 100644 --- a/apps/cli/src/commands/network-restrictions/get/get.command.ts +++ b/apps/cli/src/commands/network-restrictions/get/get.command.ts @@ -2,6 +2,10 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/go-output-flag.ts"; @@ -24,6 +28,15 @@ export type NetworkRestrictionsGetFlags = CliCommand.Command.Config.Infer Effect.gen(function* () { // Validate the -o value before the --experimental gate, so an invalid value is diff --git a/apps/cli/src/commands/network-restrictions/get/get.integration.test.ts b/apps/cli/src/commands/network-restrictions/get/get.integration.test.ts index 1d9364dcaf..ec2deab1e8 100644 --- a/apps/cli/src/commands/network-restrictions/get/get.integration.test.ts +++ b/apps/cli/src/commands/network-restrictions/get/get.integration.test.ts @@ -1,20 +1,28 @@ import { type V1GetNetworkRestrictionsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { networkRestrictionsGet } from "./get.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const APPLIED_WITH_CIDRS: typeof V1GetNetworkRestrictionsOutput.Type = { entitlement: "allowed", config: { @@ -228,6 +236,61 @@ describe("network-restrictions get integration", () => { expect(api.requests).toHaveLength(1); expect(api.requests[0]?.method).toBe("GET"); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/network-restrictions`); + assertPermissionDrift({ + command: "network-restrictions get", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the picker's projects, the restrictions and fills the real cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, 200, APPLIED_WITH_CIDRS)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* networkRestrictionsGet({ projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/network-restrictions`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toBe( + "DB Allowed IPv4 CIDRs: &[1.2.3.0/24 5.6.7.0/24]\n" + + "DB Allowed IPv6 CIDRs: &[2001:db8::/64]\n" + + "Restrictions applied successfully: true\n", + ); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "network-restrictions get", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md b/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md index e805514469..9e71d43ffe 100644 --- a/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md @@ -2,24 +2,27 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — success and HTTP failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via outermost `Effect.ensuring` — including CIDR validation failures. Not written if closed or if flag parsing fails (malformed `--db-allow-cidr` CSV). | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves (only if CIDR validation passes), on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via outermost `Effect.ensuring` — including CIDR validation failures. Not written if closed or if flag parsing fails (malformed `--db-allow-cidr` CSV). | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------- | ----------------------------------------------- | ------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| `POST` | `/v1/projects/{ref}/network-restrictions/apply` | Bearer token | `{ dbAllowedCidrs: string[], dbAllowedCidrsV6: string[] }` | `{ config: { dbAllowedCidrs?, dbAllowedCidrsV6? }, status }` (`V1UpdateNetworkRestrictionsOutput`) | -| `PATCH` | `/v1/projects/{ref}/network-restrictions` | Bearer token | `{ add: { dbAllowedCidrs: string[], dbAllowedCidrsV6: string[] } }` | `{ config: { dbAllowedCidrs?: Array<{address, type}> }, status }` (`V1PatchNetworkRestrictionsOutput`) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------- | ----------------------------------------------- | ------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `POST` | `/v1/projects/{ref}/network-restrictions/apply` | Bearer token | `{ dbAllowedCidrs: string[], dbAllowedCidrsV6: string[] }` | `{ config: { dbAllowedCidrs?, dbAllowedCidrsV6? }, status }` (`V1UpdateNetworkRestrictionsOutput`) | +| `PATCH` | `/v1/projects/{ref}/network-restrictions` | Bearer token | `{ add: { dbAllowedCidrs: string[], dbAllowedCidrsV6: string[] } }` | `{ config: { dbAllowedCidrs?: Array<{address, type}> }, status }` (`V1PatchNetworkRestrictionsOutput`) | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | `POST /apply` is the default (replace mode). `PATCH` is used when `--append=true`. @@ -28,12 +31,12 @@ when no `--db-allow-cidr` was supplied). ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -112,8 +115,7 @@ One `result` event whose `data` is the full response object. (see `--output` above). - `--append=true` switches the HTTP method (`POST /apply` → `PATCH`) and the request envelope (`{ dbAllowedCidrs, dbAllowedCidrsV6 }` → `{ add: { dbAllowedCidrs, dbAllowedCidrsV6 } }`). -- `linked-project.json` writes after a successful project-ref resolution, regardless of - whether the subsequent API call succeeds. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is open), even when the API call fails, but only on a cache miss with a token when `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed gate writes nothing. - `telemetry.json` writes on every invocation past the `--experimental` gate, including CIDR validation failures, ref resolution failures, and API failures. A closed gate writes nothing. A malformed `--db-allow-cidr` CSV value (e.g. an unterminated quote) also diff --git a/apps/cli/src/commands/network-restrictions/update/update.command.ts b/apps/cli/src/commands/network-restrictions/update/update.command.ts index 1b04cad186..f54ba55776 100644 --- a/apps/cli/src/commands/network-restrictions/update/update.command.ts +++ b/apps/cli/src/commands/network-restrictions/update/update.command.ts @@ -2,6 +2,10 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/go-output-flag.ts"; @@ -44,6 +48,27 @@ export type NetworkRestrictionsUpdateFlags = CliCommand.Command.Config.Infer Effect.gen(function* () { // Validate the -o value before the --experimental gate, so an invalid value is diff --git a/apps/cli/src/commands/network-restrictions/update/update.integration.test.ts b/apps/cli/src/commands/network-restrictions/update/update.integration.test.ts index 903f26905f..517515b301 100644 --- a/apps/cli/src/commands/network-restrictions/update/update.integration.test.ts +++ b/apps/cli/src/commands/network-restrictions/update/update.integration.test.ts @@ -4,13 +4,14 @@ import { type V1UpdateNetworkRestrictionsOutput, } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, type MockCommandPlatformApiOpts, @@ -18,12 +19,19 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { type NetworkRestrictionsUpdateFlags, networkRestrictionsUpdateDbAllowCidrFlag, } from "./update.command.ts"; import { networkRestrictionsUpdate } from "./update.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + // Runs the real --db-allow-cidr flag pipeline so these scenarios cover raw CLI values // through to the request body. const parseDbAllowCidr = (rawValues: ReadonlyArray) => @@ -149,9 +157,92 @@ describe("network-restrictions update integration", () => { "DB Allowed IPv6 CIDRs: &[2001:db8:abcd:0012::0/64]\n" + "Restrictions applied successfully: true\n", ); + assertPermissionDrift({ + command: "network-restrictions update", + activeFlags: { append: false }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("declares POST /apply only when --append is off", () => { + const { layer, api } = setup({ postResponse: POST_APPLIED }); + return Effect.gen(function* () { + yield* networkRestrictionsUpdate({ ...baseFlags, dbAllowCidr: ["12.3.4.5/32"] }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `POST https://api.supabase.com/v1/projects/${VALID_REF}/network-restrictions/apply`, + ]); + expect(() => + assertPermissionDrift({ + command: "network-restrictions update", + activeFlags: { append: true }, + requests: api.requests, + }), + ).toThrow(/condition doesn't hold/); }).pipe(Effect.provide(layer)); }); + it.effect( + "lists the picker's projects, applies the restrictions and fills the real cache", + () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, 201, POST_APPLIED)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* networkRestrictionsUpdate({ + ...baseFlags, + dbAllowCidr: ["12.3.4.5/32", "2001:db8:abcd:0012::0/64", "1.2.3.1/24"], + }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `POST https://api.supabase.com/v1/projects/${VALID_REF}/network-restrictions/apply`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toBe( + "DB Allowed IPv4 CIDRs: &[12.3.4.5/32 1.2.3.1/24]\n" + + "DB Allowed IPv6 CIDRs: &[2001:db8:abcd:0012::0/64]\n" + + "Restrictions applied successfully: true\n", + ); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "network-restrictions update", + activeFlags: { append: false }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("sends empty arrays when no --db-allow-cidr is provided", () => { const { layer, api } = setup({ postResponse: POST_EMPTY_APPLIED }); return Effect.gen(function* () { @@ -283,6 +374,31 @@ describe("network-restrictions update integration", () => { "DB Allowed IPv6 CIDRs: &[2001:db8:abcd:0012::0/64]\n" + "Restrictions applied successfully: true\n", ); + assertPermissionDrift({ + command: "network-restrictions update", + activeFlags: { append: true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("declares PATCH only when --append is on", () => { + const { layer, api } = setup({ patchResponse: PATCH_APPLIED }); + return Effect.gen(function* () { + yield* networkRestrictionsUpdate({ + ...baseFlags, + append: true, + dbAllowCidr: ["12.3.4.5/32"], + }); + expect(api.requests.map(({ method }) => method)).toEqual(["PATCH"]); + expect(() => + assertPermissionDrift({ + command: "network-restrictions update", + activeFlags: { append: false }, + requests: api.requests, + }), + ).toThrow(/condition doesn't hold/); }).pipe(Effect.provide(layer)); }); @@ -393,6 +509,12 @@ describe("network-restrictions update integration", () => { dbAllowedCidrs: ["10.0.0.0/8"], dbAllowedCidrsV6: [], }); + assertPermissionDrift({ + command: "network-restrictions update", + activeFlags: { append: false, "bypass-cidr-checks": true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md index 0108b84844..ef44434a90 100644 --- a/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md @@ -2,24 +2,27 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` - on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` - on success and failure. Not written if the gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` - on success and failure. Not written if the gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | --------------------------------------------- | ------------ | --------------------------------------------------------------- | ---------------------- | -| `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | -| `PUT` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | current config minus deleted keys (`restart_database` optional) | full JSON object | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | --------------------------------------------- | ------------ | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | +| `PUT` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | current config minus deleted keys (`restart_database` optional) | full JSON object | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | This command does not call a delete endpoint: it fetches the current config, removes the specified keys locally, then sends the remaining object back via `PUT`. @@ -27,12 +30,12 @@ The remaining stored overrides are forwarded unvalidated, so a stored key this C ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -98,10 +101,10 @@ One `result` event on success. - The `--output` flag wins over `--output-format` when both are provided. - Flags: `--config` (repeatable, config keys to delete), `--no-restart`. -- Requires `--project-ref` or a linked project (`.supabase/config.json`). +- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`), or, on an interactive terminal, the project picker. - Each config key is trimmed of whitespace before deletion. - `--no-restart` injects `restart_database = false` into the final `PUT` body. -- `linked-project.json` is written after the project ref resolves, regardless of whether the fetch or update succeeds. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is open), even when the API call fails, but only on a cache miss with a token when `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed gate writes nothing. - `telemetry.json` is written on every invocation, including failures, but only once the `--experimental` gate is open. - `postgres-config` is an experimental command: `delete` requires `--experimental` (or `SUPABASE_EXPERIMENTAL`), checked before the login check. A closed gate exits 1 diff --git a/apps/cli/src/commands/postgres-config/delete/delete.command.ts b/apps/cli/src/commands/postgres-config/delete/delete.command.ts index 6ab0cddaf5..d5defd7020 100644 --- a/apps/cli/src/commands/postgres-config/delete/delete.command.ts +++ b/apps/cli/src/commands/postgres-config/delete/delete.command.ts @@ -2,6 +2,11 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { postgresConfigRead, postgresConfigWrite } from "../postgres-config.shared.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/go-output-flag.ts"; @@ -39,6 +44,15 @@ export type PostgresConfigDeleteFlags = CliCommand.Command.Config.Infer Effect.gen(function* () { // Validate the -o value before the --experimental gate, so an invalid value is diff --git a/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md b/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md index ee9979af4f..79b64b715e 100644 --- a/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md @@ -2,32 +2,35 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` - on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` - on success and failure. Not written if the gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` - on success and failure. Not written if the gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | --------------------------------------------- | ------------ | ------------ | ---------------------- | -| `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | --------------------------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -89,8 +92,8 @@ One `result` event on success. ## Notes - The `--output` flag wins over `--output-format` when both are provided. -- Requires `--project-ref` or a linked project (`.supabase/config.json`). -- `linked-project.json` is written after the project ref resolves, regardless of whether the fetch succeeds. +- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`), or, on an interactive terminal, the project picker. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is open), even when the API call fails, but only on a cache miss with a token when `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed gate writes nothing. - `telemetry.json` is written on every invocation, including failures, but only once the `--experimental` gate is open. - `postgres-config` is an experimental command: `get` requires `--experimental` (or `SUPABASE_EXPERIMENTAL`), checked before the login check. A closed gate exits 1 diff --git a/apps/cli/src/commands/postgres-config/get/get.command.ts b/apps/cli/src/commands/postgres-config/get/get.command.ts index 564628c55e..7cabf46495 100644 --- a/apps/cli/src/commands/postgres-config/get/get.command.ts +++ b/apps/cli/src/commands/postgres-config/get/get.command.ts @@ -2,6 +2,11 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { postgresConfigRead } from "../postgres-config.shared.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/go-output-flag.ts"; @@ -24,6 +29,9 @@ export type PostgresConfigGetFlags = CliCommand.Command.Config.Infer Effect.gen(function* () { // Validate the -o value before the --experimental gate, so an invalid value is diff --git a/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts b/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts index 0c8d43567f..5b9c62deff 100644 --- a/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts +++ b/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts @@ -1,9 +1,9 @@ import { describe, expect, it } from "@effect/vitest"; import { makeApiClient } from "@supabase/api/effect"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema } from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../tests/helpers/mocks.ts"; import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts"; import { DEFAULT_API_URL, @@ -18,8 +18,17 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../tests/helpers/command-mocks.ts"; +import { + assertBlockDrift, + assertPermissionDrift, +} from "../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../tests/helpers/permission-real-layers.ts"; import { postgresConfigDelete } from "./delete/delete.handler.ts"; import { postgresConfigGet } from "./get/get.handler.ts"; +import { postgresConfigRead, postgresConfigWrite } from "./postgres-config.shared.ts"; import { postgresConfigUpdate } from "./update/update.handler.ts"; type GoOutput = "env" | "pretty" | "json" | "toml" | "yaml"; @@ -34,8 +43,56 @@ const failure = ( : undefined; }; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-postgres-config-int-"); +const PROJECTS_URL = "https://api.supabase.com/v1/projects"; +const PROJECT_URL = `${PROJECTS_URL}/${VALID_REF}`; +const CONFIG_URL = `${PROJECT_URL}/config/database/postgres`; + +const requestLines = (api: ReturnType) => + api.requests.map(({ method, url }) => `${method} ${url}`); + +// No linked ref anywhere, so the picker runs and the real cache fills after the command. +function composedRuntime(opts: { + readonly stored: Record; + readonly updated: Record; +}) { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.url === PROJECTS_URL + ? jsonResponse(request, 200, [projectResponse(VALID_REF)]) + : request.url === PROJECT_URL + ? jsonResponse(request, 200, projectResponse(VALID_REF)) + : request.url === CONFIG_URL + ? jsonResponse(request, 200, request.method === "PUT" ? opts.updated : opts.stored) + : jsonResponse(request, 404, { message: "unrouted request" }), + ), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return { out, api, layer }; +} + +const readLinkedProjectCache = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* decodeJson( + yield* fs.readFileString(`${tempRoot.current}/supabase/.temp/linked-project.json`), + ); +}); + // The shared mock disables client retries; this restores the production default so a test can // prove a command opts a request out of retrying. const withProductionRetries = ( @@ -87,6 +144,34 @@ describe("postgres-config get", () => { expect(out.stdoutText).toContain("100"); expect(api.requests[0]?.method).toBe("GET"); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/config/database/postgres`); + assertPermissionDrift({ + command: "postgres-config get", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("picks a project, reads its config and fills the linked-project cache", () => { + const { out, api, layer } = composedRuntime({ + stored: { max_connections: 100 }, + updated: {}, + }); + + return Effect.gen(function* () { + yield* postgresConfigGet({ projectRef: Option.none() }); + expect(requestLines(api)).toEqual([ + `GET ${PROJECTS_URL}`, + `GET ${CONFIG_URL}`, + `GET ${PROJECT_URL}`, + ]); + expect(out.stdoutText).toContain("max_connections"); + expect(yield* readLinkedProjectCache).toMatchObject({ ref: VALID_REF }); + assertPermissionDrift({ + command: "postgres-config get", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -330,6 +415,43 @@ describe("postgres-config update", () => { expect(out.stderrText).toBe( "- Custom Postgres Config -\n- End of Custom Postgres Config -\n", ); + assertPermissionDrift({ + command: "postgres-config update", + activeFlags: { "replace-existing-overrides": false, "no-restart": true, config: true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("picks a project, merges overrides and fills the linked-project cache", () => { + const { out, api, layer } = composedRuntime({ + stored: { wal_keep_size: "1GB" }, + updated: { wal_keep_size: "1GB", max_connections: 100 }, + }); + + return Effect.gen(function* () { + yield* postgresConfigUpdate({ + projectRef: Option.none(), + config: ["max_connections=100"], + replaceExistingOverrides: false, + noRestart: false, + }); + expect(requestLines(api)).toEqual([ + `GET ${PROJECTS_URL}`, + `GET ${CONFIG_URL}`, + `PUT ${CONFIG_URL}`, + `GET ${PROJECT_URL}`, + ]); + expect(api.requests[2]?.body).toEqual({ wal_keep_size: "1GB", max_connections: 100 }); + expect(out.stdoutText).toContain("max_connections"); + expect(yield* readLinkedProjectCache).toMatchObject({ ref: VALID_REF }); + assertPermissionDrift({ + command: "postgres-config update", + activeFlags: { "replace-existing-overrides": false }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -354,6 +476,18 @@ describe("postgres-config update", () => { expect(api.requests[0]?.body).toEqual({ max_connections: 100 }); const success = out.messages.find((message) => message.type === "success"); expect(success?.data).toEqual({ max_connections: 100 }); + assertPermissionDrift({ + command: "postgres-config update", + activeFlags: { "replace-existing-overrides": true }, + requests: api.requests, + }); + expect(() => + assertPermissionDrift({ + command: "postgres-config update", + activeFlags: { "replace-existing-overrides": true }, + requests: [{ method: "GET", url: CONFIG_URL }], + }), + ).toThrow(/condition doesn't hold/); }).pipe(Effect.provide(layer)); }); @@ -423,6 +557,11 @@ describe("postgres-config update", () => { ); expect(api.requests.map((request) => request.method)).toEqual(["GET"]); expect(cache.cached).toBe(true); + assertPermissionDrift({ + command: "postgres-config update", + activeFlags: { "replace-existing-overrides": false }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); @@ -732,6 +871,43 @@ describe("postgres-config delete", () => { expect(out.stderrText).toBe( "- Custom Postgres Config -\n- End of Custom Postgres Config -\n", ); + assertPermissionDrift({ + command: "postgres-config delete", + activeFlags: { config: true, "no-restart": true }, + requests: api.requests, + exact: true, + }); + assertBlockDrift(postgresConfigRead, { requests: api.requests.slice(0, 1), exact: true }); + assertBlockDrift(postgresConfigWrite, { requests: api.requests.slice(1, 2), exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("picks a project, deletes the override and fills the linked-project cache", () => { + const { out, api, layer } = composedRuntime({ + stored: { max_connections: 100, shared_buffers: "1GB" }, + updated: { shared_buffers: "1GB" }, + }); + + return Effect.gen(function* () { + yield* postgresConfigDelete({ + projectRef: Option.none(), + config: ["max_connections"], + noRestart: false, + }); + expect(requestLines(api)).toEqual([ + `GET ${PROJECTS_URL}`, + `GET ${CONFIG_URL}`, + `PUT ${CONFIG_URL}`, + `GET ${PROJECT_URL}`, + ]); + expect(api.requests[2]?.body).toEqual({ shared_buffers: "1GB" }); + expect(out.stdoutText).toContain("shared_buffers"); + expect(yield* readLinkedProjectCache).toMatchObject({ ref: VALID_REF }); + assertPermissionDrift({ + command: "postgres-config delete", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -922,7 +1098,11 @@ describe("postgres-config delete", () => { const causeText = Cause.pretty(exit.cause); expect(causeText).toContain("PostgresConfigGetUnexpectedStatusError"); } - expect(api.requests.map((request) => request.method)).not.toContain("PUT"); + expect(api.requests.map((request) => request.method)).toEqual(["GET"]); + assertPermissionDrift({ + command: "postgres-config delete", + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/postgres-config/postgres-config.shared.ts b/apps/cli/src/commands/postgres-config/postgres-config.shared.ts index 87f89db445..18946eb8a9 100644 --- a/apps/cli/src/commands/postgres-config/postgres-config.shared.ts +++ b/apps/cli/src/commands/postgres-config/postgres-config.shared.ts @@ -2,6 +2,7 @@ import { operationDefinitions } from "@supabase/api/effect"; import { Effect, Option, Schema, SchemaIssue } from "effect"; import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts"; +import { block } from "../../command-internal/command-permissions/model.ts"; import { OutputFlag } from "../../command-internal/global-flags.ts"; import { Output } from "../../shared/output/output.service.ts"; import { renderGlamourTable } from "../../output/glamour-table.ts"; @@ -115,6 +116,16 @@ const mapGetError = mapHttpError({ statusMessage: getStatusMessage, }); +/** `fetchCurrentPostgresConfig`: the project's stored Postgres config overrides. */ +export const postgresConfigRead = block("postgres-config-read", [ + { operationId: "v1-get-postgres-config", kind: "required" }, +]); + +/** `putPostgresConfig`: replaces the project's Postgres config overrides. */ +export const postgresConfigWrite = block("postgres-config-write", [ + { operationId: "v1-update-postgres-config", kind: "required" }, +]); + /** * Reads the stored overrides as the platform returned them. Read-modify-write callers must see * every stored key, so the response is not decoded against the typed output schema. diff --git a/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md b/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md index 6a648f5ce5..73464aed2d 100644 --- a/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md @@ -2,24 +2,27 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` - on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` - on success and failure. Not written if the gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves (only if every `--config` value is a `key=value` pair), on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` - on success and failure. Not written if the gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | --------------------------------------------- | ------------ | -------------------------------------------------------------- | ---------------------- | -| `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | -| `PUT` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | full config object (conditional GET merge unless replace mode) | full JSON object | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | --------------------------------------------- | ------------ | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | +| `PUT` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | full config object (conditional GET merge unless replace mode) | full JSON object | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | The initial `GET` is skipped when `--replace-existing-overrides` is set. Otherwise the command fetches current overrides first, merges the new values locally, then sends the final merged object back via `PUT`. @@ -27,12 +30,12 @@ The keys passed with `--config` (and `restart_database`) are validated locally a ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -99,12 +102,12 @@ One `result` event on success. ## Notes - The `--output` flag wins over `--output-format` when both are provided. -- Flags: `--config` (repeatable, parsed by splitting on the first `=`), `--replace-existing-overrides`, `--no-restart`. -- Requires `--project-ref` or a linked project (`.supabase/config.json`). +- Flags: `--config` (repeatable; each value must contain exactly one `=`, otherwise `PostgresConfigInvalidConfigValueError`), `--replace-existing-overrides`, `--no-restart`. +- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`), or, on an interactive terminal, the project picker. - Integer-like values are coerced to integers, boolean-like values are coerced to booleans, and everything else stays stringly typed before the final JSON body is serialized. - Keys ending in `_timeout` are always stringified before the `PUT`. - `--no-restart` injects `restart_database = false` into the final request body. -- `linked-project.json` is written after the project ref resolves, regardless of whether the fetch or update succeeds. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is open), even when the API call fails, but only on a cache miss with a token when `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed gate writes nothing. - `telemetry.json` is written on every invocation, including failures, but only once the `--experimental` gate is open. - `postgres-config` is an experimental command: `update` requires `--experimental` (or `SUPABASE_EXPERIMENTAL`), checked before the login check. A closed gate exits 1 diff --git a/apps/cli/src/commands/postgres-config/update/update.command.ts b/apps/cli/src/commands/postgres-config/update/update.command.ts index 2dfd853265..b8fd3f1b59 100644 --- a/apps/cli/src/commands/postgres-config/update/update.command.ts +++ b/apps/cli/src/commands/postgres-config/update/update.command.ts @@ -2,6 +2,11 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withCondition } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { postgresConfigRead, postgresConfigWrite } from "../postgres-config.shared.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/go-output-flag.ts"; @@ -45,6 +50,15 @@ export type PostgresConfigUpdateFlags = CliCommand.Command.Config.Infer Effect.gen(function* () { // Validate the -o value before the --experimental gate, so an invalid value is diff --git a/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md b/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md index f80037fc67..2d764133a2 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md @@ -2,32 +2,35 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------ | ------------ | ------------ | -------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/ssl-enforcement` | Bearer token | none | `{currentConfig: {database: boolean}, appliedSuccessfully: boolean}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------ | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/ssl-enforcement` | Bearer token | none | `{currentConfig: {database: boolean}, appliedSuccessfully: boolean}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -95,8 +98,7 @@ One `result` event: ## Notes - The `--output` flag wins over `--output-format` when both are provided. -- `linked-project.json` is written **after** the project ref is resolved, regardless of - whether the subsequent API call succeeds. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is open), even when the API call fails, but only on a cache miss with a token when `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed gate writes nothing. - `telemetry.json` is written on every invocation, including failures, but only once the `--experimental` gate is open. - `ssl-enforcement` is an experimental command: `get` requires `--experimental` diff --git a/apps/cli/src/commands/ssl-enforcement/get/get.command.ts b/apps/cli/src/commands/ssl-enforcement/get/get.command.ts index 19fbf70bd6..73268ef99b 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/get.command.ts +++ b/apps/cli/src/commands/ssl-enforcement/get/get.command.ts @@ -2,6 +2,10 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/go-output-flag.ts"; @@ -24,6 +28,15 @@ export type SslEnforcementGetFlags = CliCommand.Command.Config.Infer Effect.gen(function* () { // An invalid `-o` value must be rejected before the missing diff --git a/apps/cli/src/commands/ssl-enforcement/get/get.integration.test.ts b/apps/cli/src/commands/ssl-enforcement/get/get.integration.test.ts index afcc66a83c..97fabe90d1 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/get.integration.test.ts +++ b/apps/cli/src/commands/ssl-enforcement/get/get.integration.test.ts @@ -1,21 +1,29 @@ import { BunServices } from "@effect/platform-bun"; import { type V1GetSslEnforcementConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Path } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Path, Schema } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { sslEnforcementGet } from "./get.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const SSL_ENFORCED: typeof V1GetSslEnforcementConfigOutput.Type = { currentConfig: { database: true }, appliedSuccessfully: true, @@ -186,6 +194,60 @@ describe("ssl-enforcement get integration", () => { yield* sslEnforcementGet({ projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/ssl-enforcement`); + assertPermissionDrift({ + command: "ssl-enforcement get", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists projects for the picker and fills the real linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + const path = new URL(request.url).pathname; + if (path === "/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (path === `/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if (path === `/v1/projects/${VALID_REF}/ssl-enforcement`) { + return Effect.succeed(jsonResponse(request, 200, SSL_ENFORCED)); + } + return Effect.succeed(jsonResponse(request, 404, { message: "unexpected request" })); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* sslEnforcementGet({ projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/ssl-enforcement`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toBe("SSL is being enforced.\n"); + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "ssl-enforcement get", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md b/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md index 42de5cd8b7..9d0f59602d 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md @@ -2,32 +2,35 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after the project ref is resolved (only if flag validation passes), via `Effect.ensuring` | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — including flag-validation failures. Not written if the gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves (only if flag validation passes), on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — including flag-validation failures. Not written if the gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------ | ------------ | ---------------------------------------- | -------------------------------------------------------------------- | -| `PUT` | `/v1/projects/{ref}/ssl-enforcement` | Bearer token | `{requestedConfig: {database: boolean}}` | `{currentConfig: {database: boolean}, appliedSuccessfully: boolean}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------ | ------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `PUT` | `/v1/projects/{ref}/ssl-enforcement` | Bearer token | `{requestedConfig: {database: boolean}}` | `{currentConfig: {database: boolean}, appliedSuccessfully: boolean}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/commands/ssl-enforcement/update/update.command.ts b/apps/cli/src/commands/ssl-enforcement/update/update.command.ts index 07b00dab5e..e0e3e95f36 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/update.command.ts +++ b/apps/cli/src/commands/ssl-enforcement/update/update.command.ts @@ -2,6 +2,10 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/go-output-flag.ts"; @@ -36,6 +40,26 @@ export type SslEnforcementUpdateFlags = CliCommand.Command.Config.Infer Effect.gen(function* () { // An invalid `-o` value must be rejected before the missing diff --git a/apps/cli/src/commands/ssl-enforcement/update/update.integration.test.ts b/apps/cli/src/commands/ssl-enforcement/update/update.integration.test.ts index aba8a9bf48..97fcfcb7fa 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/update.integration.test.ts +++ b/apps/cli/src/commands/ssl-enforcement/update/update.integration.test.ts @@ -1,21 +1,29 @@ import { BunServices } from "@effect/platform-bun"; import { type V1GetSslEnforcementConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Path } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Path, Schema } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { sslEnforcementUpdate } from "./update.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const SSL_ENFORCED: typeof V1GetSslEnforcementConfigOutput.Type = { currentConfig: { database: true }, appliedSuccessfully: true, @@ -128,6 +136,18 @@ describe("ssl-enforcement update integration", () => { }), ); expect(api.requests).toHaveLength(0); + expect(() => + assertPermissionDrift({ + command: "ssl-enforcement update", + activeFlags: { "enable-db-ssl-enforcement": false, "disable-db-ssl-enforcement": false }, + requests: [ + { + method: "PUT", + url: `https://api.supabase.com/v1/projects/${VALID_REF}/ssl-enforcement`, + }, + ], + }), + ).toThrow(/condition doesn't hold/); }).pipe(Effect.provide(layer)); }); @@ -158,6 +178,67 @@ describe("ssl-enforcement update integration", () => { expect(api.requests[0]?.body).toMatchObject({ requestedConfig: { database: true }, }); + assertPermissionDrift({ + command: "ssl-enforcement update", + activeFlags: { "enable-db-ssl-enforcement": true, "disable-db-ssl-enforcement": false }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists projects for the picker and fills the real linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + const path = new URL(request.url).pathname; + if (path === "/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (path === `/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if (path === `/v1/projects/${VALID_REF}/ssl-enforcement`) { + return Effect.succeed(jsonResponse(request, 200, SSL_ENFORCED)); + } + return Effect.succeed(jsonResponse(request, 404, { message: "unexpected request" })); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* sslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `PUT https://api.supabase.com/v1/projects/${VALID_REF}/ssl-enforcement`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(api.requests[1]?.body).toMatchObject({ requestedConfig: { database: true } }); + expect(out.stdoutText).toBe("SSL is being enforced.\n"); + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "ssl-enforcement update", + activeFlags: { "enable-db-ssl-enforcement": true, "disable-db-ssl-enforcement": false }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -175,6 +256,12 @@ describe("ssl-enforcement update integration", () => { expect(api.requests[0]?.body).toMatchObject({ requestedConfig: { database: false }, }); + assertPermissionDrift({ + command: "ssl-enforcement update", + activeFlags: { "enable-db-ssl-enforcement": false, "disable-db-ssl-enforcement": true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }, ); diff --git a/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md b/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md index 62ab4db33b..20da155639 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md @@ -2,32 +2,37 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------------------------- | ------------ | ------------------------------ | --------------------------- | -| `POST` | `/v1/projects/{ref}/vanity-subdomain/activate` | Bearer token | `{ vanity_subdomain: string }` | `{ custom_domain: string }` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------------------------- | ------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `POST` | `/v1/projects/{ref}/vanity-subdomain/activate` | Bearer token | `{ vanity_subdomain: string }` | `{ custom_domain: string }` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` (key `vanity_subdomain`) — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -72,12 +77,14 @@ One `result` event with the full response object. ## Notes - The `--output` flag wins over TS `--output-format` when both are provided. -- `linked-project.json` is written after ref resolution (once the `--experimental` gate is open), - even when the API call fails. A closed gate writes nothing. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is + open), even when the API call fails, but only on a cache miss with a token when + `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed + gate writes nothing. - On gated 4xx responses this command prints an upgrade suggestion and fires `cli_upgrade_suggested`. - `--desired-subdomain` is required, but only enforced in the handler (after gate → login → ref resolution) rather than at parse time, using the fixed wording `required flag(s) "desired-subdomain" not set`. The gate/login/ref errors win over the missing - flag, and telemetry and `linked-project.json` are still written on this failure. An explicit + flag, and telemetry still fires and the cache fill still runs on this failure. An explicit empty value (`--desired-subdomain ""`) passes the check and reaches the API — only presence is required, not a non-empty value. diff --git a/apps/cli/src/commands/vanity-subdomains/activate/activate.command.ts b/apps/cli/src/commands/vanity-subdomains/activate/activate.command.ts index f14e29e3f2..745b0c45fb 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/activate.command.ts +++ b/apps/cli/src/commands/vanity-subdomains/activate/activate.command.ts @@ -2,6 +2,11 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/go-output-flag.ts"; @@ -33,6 +38,27 @@ export const vanitySubdomainsActivateCommand = Command.make("activate", config). "Activate a vanity subdomain for your Supabase project. This reconfigures your Supabase project to respond to requests on your vanity subdomain. After the vanity subdomain is activated, your project's auth services will no longer function on the {project-ref}.{supabase-domain} hostname.", ), Command.withShortDescription("Activate a vanity subdomain"), + withPermissions( + compose( + { + operations: [ + { + operationId: "v1-activate-vanity-subdomain-config", + kind: "required", + when: [{ flag: "desired-subdomain", present: true }], + }, + { + operationId: "v1-activate-vanity-subdomain-config", + kind: "required", + when: [{ flag: "desired-subdomain", oneOf: [""] }], + }, + ], + }, + upgradeSuggestion, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => Effect.gen(function* () { // An invalid `-o` value must win over a missing `--experimental` flag, so this diff --git a/apps/cli/src/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md b/apps/cli/src/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md index fce0b2dea2..8a25e3bf78 100644 --- a/apps/cli/src/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md @@ -2,32 +2,37 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------------------------------------------------- | ------------ | ------------------------------ | ------------------------ | -| `POST` | `/v1/projects/{ref}/vanity-subdomain/check-availability` | Bearer token | `{ vanity_subdomain: string }` | `{ available: boolean }` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------------------------------- | ------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `POST` | `/v1/projects/{ref}/vanity-subdomain/check-availability` | Bearer token | `{ vanity_subdomain: string }` | `{ available: boolean }` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` (key `vanity_subdomain`) — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -74,11 +79,13 @@ One `result` event with the full response object. ## Notes - The `--output` flag wins over TS `--output-format` when both are provided. -- `linked-project.json` is written after ref resolution (once the `--experimental` gate is open), - even when the API call fails. A closed gate writes nothing. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is + open), even when the API call fails, but only on a cache miss with a token when + `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed + gate writes nothing. - `--desired-subdomain` is required, but only enforced in the handler (after gate → login → ref resolution) rather than at parse time, using the fixed wording `required flag(s) "desired-subdomain" not set`. The gate/login/ref errors win over the missing - flag, and telemetry and `linked-project.json` are still written on this failure. An explicit + flag, and telemetry still fires and the cache fill still runs on this failure. An explicit empty value (`--desired-subdomain ""`) passes the check and reaches the API — only presence is required, not a non-empty value. diff --git a/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.command.ts b/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.command.ts index 23ca7b76fe..c8c1fda87f 100644 --- a/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.command.ts +++ b/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.command.ts @@ -2,6 +2,11 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/go-output-flag.ts"; @@ -34,6 +39,27 @@ export const vanitySubdomainsCheckAvailabilityCommand = Command.make( ).pipe( Command.withDescription("Checks if a desired subdomain is available for use."), Command.withShortDescription("Check subdomain availability"), + withPermissions( + compose( + { + operations: [ + { + operationId: "v1-check-vanity-subdomain-availability", + kind: "required", + when: [{ flag: "desired-subdomain", present: true }], + }, + { + operationId: "v1-check-vanity-subdomain-availability", + kind: "required", + when: [{ flag: "desired-subdomain", oneOf: [""] }], + }, + ], + }, + upgradeSuggestion, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => Effect.gen(function* () { // An invalid `-o` value must win over a missing `--experimental` flag, so this diff --git a/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md index ce9582a617..bd069d41c6 100644 --- a/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md @@ -2,32 +2,35 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ------------------------------------- | ------------ | ------------ | ---------------------- | -| `DELETE` | `/v1/projects/{ref}/vanity-subdomain` | Bearer token | none | none | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `DELETE` | `/v1/projects/{ref}/vanity-subdomain` | Bearer token | none | none | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -70,5 +73,7 @@ One `result` event when the `--output` flag is unset. ## Notes - The `--output` flag wins over TS `--output-format` when both are provided. -- `linked-project.json` is written after ref resolution (once the `--experimental` gate is open), - even when the API call fails. A closed gate writes nothing. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is + open), even when the API call fails, but only on a cache miss with a token when + `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed + gate writes nothing. diff --git a/apps/cli/src/commands/vanity-subdomains/delete/delete.command.ts b/apps/cli/src/commands/vanity-subdomains/delete/delete.command.ts index 32550e8186..4a72421cae 100644 --- a/apps/cli/src/commands/vanity-subdomains/delete/delete.command.ts +++ b/apps/cli/src/commands/vanity-subdomains/delete/delete.command.ts @@ -2,6 +2,10 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/go-output-flag.ts"; @@ -26,6 +30,15 @@ export const vanitySubdomainsDeleteCommand = Command.make("delete", config).pipe "Deletes the vanity subdomain for a project, and reverts to using the project ref for routing.", ), Command.withShortDescription("Delete the vanity subdomain"), + withPermissions( + compose( + { + operations: [{ operationId: "v1-deactivate-vanity-subdomain-config", kind: "required" }], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => Effect.gen(function* () { // An invalid `-o` value must win over a missing `--experimental` flag, so this diff --git a/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md b/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md index 5c1a47d575..b6d6c4798b 100644 --- a/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md @@ -2,32 +2,35 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------- | ------------ | ------------ | -------------------------------------------- | -| `GET` | `/v1/projects/{ref}/vanity-subdomain` | Bearer token | none | `{ status: string, custom_domain?: string }` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/vanity-subdomain` | Bearer token | none | `{ status: string, custom_domain?: string }` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -74,5 +77,7 @@ One `result` event with the full response object. ## Notes - The `--output` flag wins over TS `--output-format` when both are provided. -- `linked-project.json` is written after ref resolution (once the `--experimental` gate is open), - even when the API call fails. A closed gate writes nothing. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is + open), even when the API call fails, but only on a cache miss with a token when + `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed + gate writes nothing. diff --git a/apps/cli/src/commands/vanity-subdomains/get/get.command.ts b/apps/cli/src/commands/vanity-subdomains/get/get.command.ts index 2cbe24655a..81c5a869f8 100644 --- a/apps/cli/src/commands/vanity-subdomains/get/get.command.ts +++ b/apps/cli/src/commands/vanity-subdomains/get/get.command.ts @@ -2,6 +2,10 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/go-output-flag.ts"; @@ -24,6 +28,15 @@ export type VanitySubdomainsGetFlags = CliCommand.Command.Config.Infer Effect.gen(function* () { // An invalid `-o` value must win over a missing `--experimental` flag, so this diff --git a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts index 5ce6fcc4a4..576186b663 100644 --- a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts +++ b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts @@ -5,9 +5,9 @@ import type { V1GetVanitySubdomainConfigOutput, } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockAnalytics, mockOutput } from "../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, @@ -18,6 +18,11 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../tests/helpers/permission-real-layers.ts"; import { vanitySubdomainsActivate } from "./activate/activate.handler.ts"; import { vanitySubdomainsCheckAvailability } from "./check-availability/check-availability.handler.ts"; import { vanitySubdomainsDelete } from "./delete/delete.handler.ts"; @@ -30,6 +35,13 @@ type ProjectResponse = typeof V1GetProjectOutput.Type; const tempRoot = useTempWorkdir("supabase-vanity-int-"); +const API_URL = "https://api.supabase.com"; + +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const requestLines = (api: ReturnType) => + api.requests.map(({ method, url }) => `${method} ${url}`); + const SAMPLE_GET: VanityConfig = { status: "custom-domain-used", custom_domain: "example.com", @@ -116,6 +128,34 @@ function gatedApi(matchWrite: (url: string) => boolean) { }); } +// No project is configured: the picker lists projects, the command makes its own call, and the +// real linked-project cache then looks the project up. +function pickerComposition(own: { readonly status: number; readonly body: unknown }) { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.method === "GET" && request.url === `${API_URL}/v1/projects`) { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.method === "GET" && request.url === `${API_URL}/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, own.status, own.body)); + }, + }); + const cliSettings = mockCommandSettings({ workdir: tempRoot.current, projectId: Option.none() }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return { out, api, layer }; +} + +const linkedProjectCachePath = () => `${tempRoot.current}/supabase/.temp/linked-project.json`; + describe("vanity-subdomains get", () => { it.effect("prints status and subdomain in text mode", () => { const out = mockOutput({ format: "text" }); @@ -127,6 +167,35 @@ describe("vanity-subdomains get", () => { expect(out.stdoutText).toBe("Status: custom-domain-used\nVanity subdomain: example.com\n"); expect(api.requests[0]?.method).toBe("GET"); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/vanity-subdomain`); + assertPermissionDrift({ + command: "vanity-subdomains get", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the picker's projects, reads the config and fills the real cache", () => { + const { out, api, layer } = pickerComposition({ status: 200, body: SAMPLE_GET }); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + expect(yield* fs.exists(linkedProjectCachePath())).toBe(false); + yield* vanitySubdomainsGet({ projectRef: Option.none() }); + expect(requestLines(api)).toEqual([ + `GET ${API_URL}/v1/projects`, + `GET ${API_URL}/v1/projects/${VALID_REF}/vanity-subdomain`, + `GET ${API_URL}/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toBe("Status: custom-domain-used\nVanity subdomain: example.com\n"); + expect(yield* decodeJson(yield* fs.readFileString(linkedProjectCachePath()))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "vanity-subdomains get", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -152,6 +221,7 @@ describe("vanity-subdomains get", () => { const exit = yield* Effect.exit(vanitySubdomainsGet({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); expect(api.requests).toHaveLength(1); + assertPermissionDrift({ command: "vanity-subdomains get", requests: api.requests }); expect(out.stderrText).toContain("Upgrade your plan:"); expect(out.stderrText).toContain("/org/env-org/billing"); expect(analytics.captured).toEqual([ @@ -326,6 +396,40 @@ describe("vanity-subdomains check-availability", () => { `/v1/projects/${VALID_REF}/vanity-subdomain/check-availability`, ); expect(api.requests[0]?.body).toEqual({ vanity_subdomain: "example.com" }); + assertPermissionDrift({ + command: "vanity-subdomains check-availability", + activeFlags: { "desired-subdomain": "example.com" }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the picker's projects, checks availability and fills the real cache", () => { + const { out, api, layer } = pickerComposition({ status: 201, body: SAMPLE_CHECK }); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + expect(yield* fs.exists(linkedProjectCachePath())).toBe(false); + yield* vanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: Option.some("example.com"), + }); + expect(requestLines(api)).toEqual([ + `GET ${API_URL}/v1/projects`, + `POST ${API_URL}/v1/projects/${VALID_REF}/vanity-subdomain/check-availability`, + `GET ${API_URL}/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toBe("Subdomain example.com available: true\n"); + expect(yield* decodeJson(yield* fs.readFileString(linkedProjectCachePath()))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "vanity-subdomains check-availability", + activeFlags: { "desired-subdomain": "example.com" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -416,6 +520,16 @@ describe("vanity-subdomains check-availability", () => { expect(Exit.isFailure(exit)).toBe(true); expect(out.stderrText).toContain("Upgrade your plan:"); expect(analytics.captured).toHaveLength(0); + expect(requestLines(api)).toEqual([ + `POST ${API_URL}/v1/projects/${VALID_REF}/vanity-subdomain/check-availability`, + `GET ${API_URL}/v1/projects/${VALID_REF}`, + `GET ${API_URL}/v1/organizations/supabase/entitlements`, + ]); + assertPermissionDrift({ + command: "vanity-subdomains check-availability", + activeFlags: { "desired-subdomain": "example.com" }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); @@ -456,6 +570,18 @@ describe("vanity-subdomains check-availability", () => { expect(error._tag).toBe("DesiredSubdomainRequiredError"); expect(error.message).toBe('required flag(s) "desired-subdomain" not set'); expect(api.requests).toHaveLength(0); + expect(() => + assertPermissionDrift({ + command: "vanity-subdomains check-availability", + activeFlags: {}, + requests: [ + { + method: "POST", + url: `${API_URL}/v1/projects/${VALID_REF}/vanity-subdomain/check-availability`, + }, + ], + }), + ).toThrow(/condition doesn't hold/); }).pipe(Effect.provide(layer)); }); @@ -471,6 +597,12 @@ describe("vanity-subdomains check-availability", () => { }); expect(api.requests[0]?.body).toEqual({ vanity_subdomain: "" }); expect(out.stdoutText).toBe("Subdomain available: true\n"); + assertPermissionDrift({ + command: "vanity-subdomains check-availability", + activeFlags: { "desired-subdomain": "" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); }); @@ -490,6 +622,40 @@ describe("vanity-subdomains activate", () => { expect(api.requests[0]?.method).toBe("POST"); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/vanity-subdomain/activate`); expect(api.requests[0]?.body).toEqual({ vanity_subdomain: "example.com" }); + assertPermissionDrift({ + command: "vanity-subdomains activate", + activeFlags: { "desired-subdomain": "example.com" }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the picker's projects, activates the subdomain and fills the real cache", () => { + const { out, api, layer } = pickerComposition({ status: 201, body: SAMPLE_ACTIVATE }); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + expect(yield* fs.exists(linkedProjectCachePath())).toBe(false); + yield* vanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: Option.some("example.com"), + }); + expect(requestLines(api)).toEqual([ + `GET ${API_URL}/v1/projects`, + `POST ${API_URL}/v1/projects/${VALID_REF}/vanity-subdomain/activate`, + `GET ${API_URL}/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toBe("Activated vanity subdomain at example.com\n"); + expect(yield* decodeJson(yield* fs.readFileString(linkedProjectCachePath()))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "vanity-subdomains activate", + activeFlags: { "desired-subdomain": "example.com" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -586,6 +752,16 @@ describe("vanity-subdomains activate", () => { properties: { feature_key: "vanity_subdomain", org_slug: "supabase" }, }, ]); + expect(requestLines(api)).toEqual([ + `POST ${API_URL}/v1/projects/${VALID_REF}/vanity-subdomain/activate`, + `GET ${API_URL}/v1/projects/${VALID_REF}`, + `GET ${API_URL}/v1/organizations/supabase/entitlements`, + ]); + assertPermissionDrift({ + command: "vanity-subdomains activate", + activeFlags: { "desired-subdomain": "example.com" }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); @@ -628,6 +804,18 @@ describe("vanity-subdomains activate", () => { expect(error._tag).toBe("DesiredSubdomainRequiredError"); expect(error.message).toBe('required flag(s) "desired-subdomain" not set'); expect(api.requests).toHaveLength(0); + expect(() => + assertPermissionDrift({ + command: "vanity-subdomains activate", + activeFlags: {}, + requests: [ + { + method: "POST", + url: `${API_URL}/v1/projects/${VALID_REF}/vanity-subdomain/activate`, + }, + ], + }), + ).toThrow(/condition doesn't hold/); }).pipe(Effect.provide(layer)); }); @@ -642,6 +830,12 @@ describe("vanity-subdomains activate", () => { desiredSubdomain: Option.some(""), }); expect(api.requests[0]?.body).toEqual({ vanity_subdomain: "" }); + assertPermissionDrift({ + command: "vanity-subdomains activate", + activeFlags: { "desired-subdomain": "" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); }); @@ -657,6 +851,35 @@ describe("vanity-subdomains delete", () => { expect(out.stderrText).toBe("Deleted vanity subdomain successfully.\n"); expect(api.requests[0]?.method).toBe("DELETE"); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/vanity-subdomain`); + assertPermissionDrift({ + command: "vanity-subdomains delete", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the picker's projects, deletes the subdomain and fills the real cache", () => { + const { out, api, layer } = pickerComposition({ status: 200, body: null }); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + expect(yield* fs.exists(linkedProjectCachePath())).toBe(false); + yield* vanitySubdomainsDelete({ projectRef: Option.none() }); + expect(requestLines(api)).toEqual([ + `GET ${API_URL}/v1/projects`, + `DELETE ${API_URL}/v1/projects/${VALID_REF}/vanity-subdomain`, + `GET ${API_URL}/v1/projects/${VALID_REF}`, + ]); + expect(out.stderrText).toContain("Deleted vanity subdomain successfully.\n"); + expect(yield* decodeJson(yield* fs.readFileString(linkedProjectCachePath()))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "vanity-subdomains delete", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/tests/helpers/permission-pending-commands.ts b/apps/cli/tests/helpers/permission-pending-commands.ts index ac6de1bb24..e39ac531ba 100644 --- a/apps/cli/tests/helpers/permission-pending-commands.ts +++ b/apps/cli/tests/helpers/permission-pending-commands.ts @@ -18,9 +18,6 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "db branch switch", "db remote changes", - "encryption get-root-key", - "encryption update-root-key", - "feedback add", "feedback delete", @@ -43,21 +40,8 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "logout", - "network-bans get", - "network-bans remove", - - "network-restrictions get", - "network-restrictions update", - - "postgres-config delete", - "postgres-config get", - "postgres-config update", - "pull", - "ssl-enforcement get", - "ssl-enforcement update", - "stack destroy", "stack list", "stack logs", @@ -77,10 +61,5 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "telemetry enable", "telemetry status", - "vanity-subdomains activate", - "vanity-subdomains check-availability", - "vanity-subdomains delete", - "vanity-subdomains get", - "whoami", ]; From d9a2ee2d87874a06bde69c4d634b43c5a49b6c09 Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Thu, 15 Oct 2026 09:52:41 +0000 Subject: [PATCH 14/26] chore(cli): map functions and compute command permissions (#7173) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps which Management API operations the `functions` and `compute` commands call, and under which flags, so scoped tokens can be granted the right permissions. The mapping is recorded in each command's declaration, and integration tests check it against the requests the handlers actually make. There is no user-facing change. **Commands (12):** `functions` list, delete, download, deploy, new, serve; `compute` list, status, delete, logs, push, new. **Blocks used:** `projectRefPicker` and `linkedProjectCache` on every command that calls the API. **New shared blocks:** - `functionsDownload` in `src/shared/functions/download.ts`, next to `downloadFunctions`, so `pull` can compose it later. - `computeRead` in `src/shared/compute/compute-api.ts`, used by status, delete, logs and push. **`is` conditions declared:** - `functions deploy`: `use-api`, `use-docker`, `no-verify-jwt` and `prune` select the API path, the Docker path, the JWT list lookup and prune. - `compute logs`: `follow`. - `compute push`: `no-wait`. **Notes** - `functions download` lists `--use-api`, `--use-docker` and `--legacy-bundle` as flags that don't change the call set. Under `--legacy-bundle` the Go binary makes its own list, get and body calls, which aren't recorded. - `compute delete` marks its lookup best-effort: it carries on to the DELETE after a 403 on the GET. - `compute logs` needs the `analytics_logs_read` permission, unlike the other compute commands. - `functions new`, `functions serve` and `compute new` make no API calls and declare none. Their layers provide no API client, so a call fails type-checking. - The context-only entries (deploy create, update, bulk update, prune delete and the API-path list; download's get and list; logs' history and compute lookup) carry a `context` and are asserted through explicit request sequences, not `exact` drift checks. - `--no-verify-jwt=false` on deploy over-declares the API-path list call, and `compute logs --tail 0` can't be expressed as a condition. - The registry unit test's "undeclared command in a gated tree" example moves from `compute list` to `stack list`. - `OPERATIONS_WITHOUT_FGA`: no change. The `SIDE_EFFECTS.md` commit corrects the cache-fill wording and adds the missing picker and cache rows for the functions and compute commands. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 --- .../compute/delete/SIDE_EFFECTS.md | 19 +- .../compute/delete/delete.command.ts | 13 + .../compute/delete/delete.handler.ts | 4 +- .../compute/delete/delete.integration.test.ts | 64 +++- .../experimental/compute/list/SIDE_EFFECTS.md | 17 +- .../experimental/compute/list/list.command.ts | 11 + .../compute/list/list.integration.test.ts | 69 +++- .../experimental/compute/logs/SIDE_EFFECTS.md | 19 +- .../experimental/compute/logs/logs.command.ts | 27 ++ .../compute/logs/logs.integration.test.ts | 133 ++++++- .../experimental/compute/new/new.command.ts | 5 + .../experimental/compute/push/SIDE_EFFECTS.md | 27 +- .../experimental/compute/push/push.command.ts | 26 ++ .../compute/push/push.integration.test.ts | 69 ++++ .../compute/status/SIDE_EFFECTS.md | 17 +- .../compute/status/status.command.ts | 6 + .../compute/status/status.integration.test.ts | 73 +++- .../commands/functions/delete/SIDE_EFFECTS.md | 12 +- .../functions/delete/delete.command.ts | 11 + .../delete/delete.integration.test.ts | 98 ++++- .../commands/functions/deploy/SIDE_EFFECTS.md | 18 +- .../functions/deploy/deploy.command.ts | 80 ++++ .../deploy/deploy.integration.test.ts | 357 +++++++++++++++++- .../functions/download/SIDE_EFFECTS.md | 32 +- .../functions/download/download.command.ts | 16 + .../download/download.integration.test.ts | 154 +++++++- .../commands/functions/list/SIDE_EFFECTS.md | 8 +- .../commands/functions/list/list.command.ts | 11 + .../functions/list/list.integration.test.ts | 93 ++++- .../src/commands/functions/new/new.command.ts | 2 + .../commands/functions/serve/serve.command.ts | 13 + apps/cli/src/shared/compute/compute-api.ts | 8 +- apps/cli/src/shared/functions/download.ts | 17 + apps/cli/tests/helpers/compute.ts | 47 ++- .../helpers/permission-pending-commands.ts | 14 - .../helpers/permission-registry.unit.test.ts | 4 +- 36 files changed, 1468 insertions(+), 126 deletions(-) diff --git a/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md index 31816fc293..134ab025cb 100644 --- a/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md @@ -28,10 +28,10 @@ and the command handler does not run. See the [Compute command guide](../../../. ## Files Written -| Path | Format | When | -| ----------------------------------------------- | ------ | --------------------------------------------------------------- | -| `/telemetry.json` | JSON | always — flushed on success and on failure | -| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | post-run, once the project ref has resolved (success or failure), only when no such file exists, `/supabase/.temp/project-ref` doesn't name another ref, an access token exists and `GET /v1/projects/{ref}` returns 200 | The compute's directory and its `[compute.]` entry are deliberately left on disk; only the remote compute is deleted. @@ -46,11 +46,12 @@ rather than deleting unasked. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ----------------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none | `instances.live` when present, else `spec.instances` (for the confirmation). A `403` is tolerated: the compute is treated as unknown and the `DELETE` still runs, since the two endpoints are granted separately (`edge_functions:read` vs `edge_functions:write`) | -| `DELETE` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none | status only | -| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ----------------------------------- | ------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none | `instances.live` when present, else `spec.instances` (for the confirmation). A `403` is tolerated: the compute is treated as unknown and the `DELETE` still runs, since the two endpoints are granted separately (`workers_read` vs `workers_write`) | +| `DELETE` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none | status only. Not sent when the `GET` shows nothing deployed under that name | +| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | ## Exit Codes diff --git a/apps/cli/src/commands/experimental/compute/delete/delete.command.ts b/apps/cli/src/commands/experimental/compute/delete/delete.command.ts index e6939083e8..2c942a3215 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.command.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.command.ts @@ -1,5 +1,10 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withKind } from "../../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../../telemetry/linked-project-cache.layer.ts"; +import { computeRead } from "../../../../shared/compute/compute-api.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; @@ -33,6 +38,14 @@ export const computeDeleteCommand = Command.make("delete", config).pipe( description: "Skip the confirmation prompt (scripts and CI)", }, ]), + withPermissions( + compose( + withKind(computeRead, "best-effort"), + { operations: [{ operationId: "v2-delete-a-compute-instance", kind: "required" }] }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => computeDelete(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts b/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts index 02d4e86192..c73878224e 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts @@ -69,8 +69,8 @@ export const computeDelete = Effect.fn("compute.delete")(function* (flags: Compu const fetching = yield* output.task("Fetching compute..."); // The lookup supplies the instance tally the confirmation quotes and the // "already gone" verdict. A *refusal* is not a prerequisite: the API grants - // the read and the delete separately — `edge_functions:read` for `GET`, - // `edge_functions:write` for `DELETE` — so a credential holding only the + // the read and the delete separately — `workers_read` for `GET`, + // `workers_write` for `DELETE` — so a credential holding only the // latter would not be able to delete a compute it is entitled to delete. A // refused read leaves the compute unknown and the delete goes ahead. A read // that cannot reach compute at all — unserved route, unenrolled project, no diff --git a/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts b/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts index ac2c346f89..1558bba095 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts @@ -8,6 +8,8 @@ import { computeRoute, COMPUTE_PROJECT_REF, } from "../../../../../tests/helpers/compute.ts"; +import { assertPermissionDrift } from "../../../../../tests/helpers/permission-drift.ts"; +import { projectResponse } from "../../../../../tests/helpers/permission-real-layers.ts"; import { ComputeDeleteConfirmationRequiredError, ComputeDeleteNotConfirmedError, @@ -64,6 +66,12 @@ describe("compute delete", () => { }); expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + assertPermissionDrift({ + command: "compute delete", + variant: { computeEnabled: true }, + requests: http.driftRequests, + exact: true, + }); expect(out.stdoutText).toContain("permanently deletes"); // Labelled "declared" because this response carries no live tally. // `spec.instances` is the target, not what is running. @@ -83,6 +91,47 @@ describe("compute delete", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.effect("picks the project, deletes the compute and records the linked project", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, out, http } = setupCompute({ + workdir: repo.dir, + realLayers: true, + interactive: true, + promptSelectResponses: [COMPUTE_PROJECT_REF], + promptTextResponses: ["api"], + routes: { + ...routes, + "GET /v1/projects": { status: 200, body: [projectResponse(COMPUTE_PROJECT_REF)] }, + [`GET /v1/projects/${COMPUTE_PROJECT_REF}`]: { + status: 200, + body: projectResponse(COMPUTE_PROJECT_REF), + }, + }, + }); + + return yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* computeDelete({ name: "api", projectRef: Option.none() }); + + expect(http.routeKeys).toEqual([ + "GET /v1/projects", + getRoute, + deleteRoute, + `GET /v1/projects/${COMPUTE_PROJECT_REF}`, + ]); + expect(out.promptSelectCalls).toHaveLength(1); + expect(out.stdoutText).toContain("Deleted Compute"); + expect(yield* fs.exists(`${repo.dir}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "compute delete", + variant: { computeEnabled: true }, + requests: http.driftRequests, + }); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + // The refusal has to precede the DELETE. At emit time `--yes -o env` would // remove the compute and then exit non-zero with no payload, which a script // reads as "the delete failed" and may retry. @@ -112,7 +161,7 @@ describe("compute delete", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - // The API grants `edge_functions:read` for the GET and `edge_functions:write` + // The API grants `workers_read` for the GET and `workers_write` // for the DELETE separately, so a credential holding only write could not // delete a compute it is entitled to delete. it.effect("deletes with --yes when the credential may not read the compute", () => @@ -131,6 +180,11 @@ describe("compute delete", () => { yield* computeDelete({ name: "api", projectRef: Option.none() }); expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + assertPermissionDrift({ + command: "compute delete", + variant: { computeEnabled: true }, + requests: http.driftRequests, + }); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -162,7 +216,7 @@ describe("compute delete", () => { it.effect("reports an unreadable compute as deleted, not as nothing to delete", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, out } = setupCompute({ + const { layer, out, http } = setupCompute({ workdir: repo.dir, yes: true, routes: { @@ -176,6 +230,12 @@ describe("compute delete", () => { expect(out.stdoutText).toContain("Deleted Compute"); expect(out.stdoutText).not.toContain("nothing to delete"); + expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + assertPermissionDrift({ + command: "compute delete", + variant: { computeEnabled: true }, + requests: http.driftRequests, + }); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); diff --git a/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md index 8e300abeba..a4cfcb5d07 100644 --- a/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md @@ -28,17 +28,18 @@ and the command handler does not run. See the [Compute command guide](../../../. ## Files Written -| Path | Format | When | -| ----------------------------------------------- | ------ | --------------------------------------------------------------- | -| `/telemetry.json` | JSON | always — flushed on success and on failure | -| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | post-run, once the project ref has resolved (success or failure), only when no such file exists, `/supabase/.temp/project-ref` doesn't name another ref, an access token exists and `GET /v1/projects/{ref}` returns 200 | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/v2/projects/{ref}/compute` | Bearer token | none | `data[].id`, `data[].attributes.spec/build_state/deleting` | -| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------- | ------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v2/projects/{ref}/compute` | Bearer token | none | `data[].id`, `data[].attributes.spec/build_state/deleting` | +| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | ## Exit Codes diff --git a/apps/cli/src/commands/experimental/compute/list/list.command.ts b/apps/cli/src/commands/experimental/compute/list/list.command.ts index 9e16aaa99a..c59799f059 100644 --- a/apps/cli/src/commands/experimental/compute/list/list.command.ts +++ b/apps/cli/src/commands/experimental/compute/list/list.command.ts @@ -1,5 +1,9 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; @@ -25,6 +29,13 @@ export const computeListCommand = Command.make("list", config).pipe( description: "See every compute in the linked project", }, ]), + withPermissions( + compose( + { operations: [{ operationId: "v2-list-all-compute-instances", kind: "required" }] }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => computeList(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts b/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts index 637cb75cc1..b9915b18c7 100644 --- a/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts @@ -8,6 +8,8 @@ import { computeRoute, COMPUTE_PROJECT_REF, } from "../../../../../tests/helpers/compute.ts"; +import { assertPermissionDrift } from "../../../../../tests/helpers/permission-drift.ts"; +import { projectResponse } from "../../../../../tests/helpers/permission-real-layers.ts"; import { ProjectRefNotLinkedError } from "../../../../config/project-ref.errors.ts"; import { ComputeEnvNotSupportedError } from "../compute.errors.ts"; import { @@ -38,7 +40,7 @@ describe("compute list", () => { it.effect("shows configured and deployed compute as one inventory", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, out } = setupCompute({ + const { layer, out, http } = setupCompute({ workdir: repo.dir, routes: { [listRoute]: { @@ -75,6 +77,56 @@ describe("compute list", () => { expect(stdout).not.toContain("https://"); expect(rows[1]).toContain("sandbox"); expect(rows[2]).toContain("not deployed"); + expect(http.routeKeys).toEqual([listRoute]); + assertPermissionDrift({ + command: "compute list", + variant: { computeEnabled: true }, + requests: http.driftRequests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect("picks the project, lists its compute and records the linked project", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, out, http } = setupCompute({ + workdir: repo.dir, + realLayers: true, + interactive: true, + promptSelectResponses: [COMPUTE_PROJECT_REF], + routes: { + "GET /v1/projects": { status: 200, body: [projectResponse(COMPUTE_PROJECT_REF)] }, + [`GET /v1/projects/${COMPUTE_PROJECT_REF}`]: { + status: 200, + body: projectResponse(COMPUTE_PROJECT_REF), + }, + [listRoute]: { + status: 200, + body: { data: [computeResource({ name: "api", runtime: "node" })] }, + }, + }, + }); + + return yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* computeList({ projectRef: Option.none() }); + + expect(http.routeKeys).toEqual([ + "GET /v1/projects", + listRoute, + `GET /v1/projects/${COMPUTE_PROJECT_REF}`, + ]); + expect(out.promptSelectCalls).toHaveLength(1); + expect(out.stdoutText).toContain("api"); + expect(yield* fs.exists(`${repo.dir}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "compute list", + variant: { computeEnabled: true }, + requests: http.driftRequests, + exact: true, + }); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -439,7 +491,7 @@ describe("compute list", () => { it.effect("surfaces an unexpected status rather than showing an empty list", () => Effect.gen(function* () { const repo = yield* project(); - const { layer } = setupCompute({ + const { layer, http } = setupCompute({ workdir: repo.dir, routes: { [listRoute]: { status: 500, body: { message: "boom" } } }, }); @@ -448,6 +500,12 @@ describe("compute list", () => { const error = yield* computeList({ projectRef: Option.none() }).pipe(Effect.flip); expect(error).toBeInstanceOf(ComputeApiUnexpectedStatusError); + expect(http.routeKeys).toEqual([listRoute]); + assertPermissionDrift({ + command: "compute list", + variant: { computeEnabled: true }, + requests: http.driftRequests, + }); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -467,6 +525,13 @@ describe("compute list", () => { yield* computeList({ projectRef: Option.some("qrstuvwxyzabcdefghij") }); expect(http.routeKeys).toEqual(["GET /v2/projects/qrstuvwxyzabcdefghij/compute"]); + assertPermissionDrift({ + command: "compute list", + variant: { computeEnabled: true }, + activeFlags: { "project-ref": "qrstuvwxyzabcdefghij" }, + requests: http.driftRequests, + exact: true, + }); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); diff --git a/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md index babc1b631c..00e85c2290 100644 --- a/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md @@ -30,18 +30,19 @@ command's output depends on local state — there is no source path to report ## Files Written -| Path | Format | When | -| ----------------------------------------------- | ------ | --------------------------------------------------------------- | -| `/telemetry.json` | JSON | always — flushed on success and on failure | -| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | post-run, once the project ref has resolved (success or failure), only when no such file exists, `/supabase/.temp/project-ref` doesn't name another ref, an access token exists and `GET /v1/projects/{ref}` returns 200 | ## API Routes -| Method | Path | Auth | Request | Response (used fields) | -| ------ | --------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------- | ---------------------- | -| `GET` | `/v1/projects/{ref}/analytics/endpoints/logs` | Bearer token | `sql`, `iso_timestamp_start`, `iso_timestamp_end` as query parameters | `result[]`, `error` | -| `GET` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none — **only when the log query returned no rows**, to tell "not deployed" from "deployed and quiet" | presence only | -| `GET` | `/v1/projects` | Bearer token | none — only when no ref resolved and the session is interactive | project picker | +| Method | Path | Auth | Request | Response (used fields) | +| ------ | --------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/analytics/endpoints/logs` | Bearer token | `sql`, `iso_timestamp_start`, `iso_timestamp_end` as query parameters | `result[]`, `error` | +| `GET` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none — **only when there are no log rows** (the query returned none, or `--tail 0 --follow` ran no query), to tell "not deployed" from "deployed and quiet" | presence only | +| `GET` | `/v1/projects` | Bearer token | none — only when no ref resolved and the session is interactive | project picker | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | Requires the `analytics_logs_read` permission, and the project must be on the Compute private-alpha allow-list — an unenrolled project answers 404. diff --git a/apps/cli/src/commands/experimental/compute/logs/logs.command.ts b/apps/cli/src/commands/experimental/compute/logs/logs.command.ts index a4d2633215..7221e4701c 100644 --- a/apps/cli/src/commands/experimental/compute/logs/logs.command.ts +++ b/apps/cli/src/commands/experimental/compute/logs/logs.command.ts @@ -1,5 +1,10 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withContext } from "../../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../../telemetry/linked-project-cache.layer.ts"; +import { computeRead } from "../../../../shared/compute/compute-api.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../../command-internal/management-api-runtime.layer.ts"; import { @@ -85,6 +90,28 @@ export const computeLogsCommand = Command.make("logs", config).pipe( description: "Skip the backlog and print only lines that arrive from now on", }, ]), + withPermissions( + compose( + { + operations: [ + { + operationId: "v1-get-project-logs", + kind: "required", + context: "--tail isn't 0", + }, + { + operationId: "v1-get-project-logs", + kind: "required", + when: [{ flag: "follow", is: true }], + }, + ], + noApiEffectFlags: ["kind", "tail"], + }, + withContext(computeRead, "the log query returned no rows, or --tail 0 with --follow"), + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => computeLogs(flags).pipe( // `config` as well as `flags`: `--kind` is a choice flag, and the wrapper diff --git a/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts b/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts index d6efe31746..14dff50520 100644 --- a/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts @@ -4,6 +4,7 @@ import { Cause, Effect, Exit, + FileSystem, Layer, Option, Schedule, @@ -32,6 +33,8 @@ import { mockCommandSettings, VALID_TOKEN, } from "../../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../../tests/helpers/permission-drift.ts"; +import { projectResponse } from "../../../../../tests/helpers/permission-real-layers.ts"; import { mockContextualAnalytics, mockRuntimeInfo, @@ -161,7 +164,7 @@ describe("compute logs", () => { it.effect("prints a compute's own output oldest first", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, out } = setupCompute({ + const { layer, out, http } = setupCompute({ workdir: repo.dir, routes: { [LOGS_ROUTE]: logsResponse([ @@ -182,6 +185,53 @@ describe("compute logs", () => { .split("\n") .map((line) => line.replace(/^\S+\s+\[\S+\]\s+/u, "")); expect(messages).toEqual(["listening on :8080", "terminate hook", "app drained"]); + expect(http.routeKeys).toEqual([LOGS_ROUTE]); + assertPermissionDrift({ + command: "compute logs", + variant: { computeEnabled: true }, + activeFlags: { follow: false }, + requests: http.driftRequests, + }); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect("picks the project, reads its logs and records the linked project", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, out, http } = setupCompute({ + workdir: repo.dir, + realLayers: true, + interactive: true, + promptSelectResponses: [COMPUTE_PROJECT_REF], + routes: { + "GET /v1/projects": { status: 200, body: [projectResponse(COMPUTE_PROJECT_REF)] }, + [`GET /v1/projects/${COMPUTE_PROJECT_REF}`]: { + status: 200, + body: projectResponse(COMPUTE_PROJECT_REF), + }, + [LOGS_ROUTE]: logsResponse([computeLogRow({ message: "listening on :8080" })]), + }, + }); + + return yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* computeLogs(flags()); + + expect(http.routeKeys).toEqual([ + "GET /v1/projects", + LOGS_ROUTE, + `GET /v1/projects/${COMPUTE_PROJECT_REF}`, + ]); + expect(out.promptSelectCalls).toHaveLength(1); + expect(out.stdoutText).toContain("listening on :8080"); + expect(yield* fs.exists(`${repo.dir}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "compute logs", + variant: { computeEnabled: true }, + activeFlags: { follow: false }, + requests: http.driftRequests, + }); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -398,6 +448,44 @@ describe("compute logs", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + const tailZeroRuns: ReadonlyArray<{ + readonly argv: string; + readonly kind: Option.Option<"requests">; + readonly activeFlags: Readonly>; + }> = [ + { argv: "--tail 0", kind: Option.none(), activeFlags: { tail: "0", follow: false } }, + { + argv: "--tail 0 --kind requests", + kind: Option.some("requests"), + activeFlags: { tail: "0", kind: "requests", follow: false }, + }, + ]; + + it.effect.each(tailZeroRuns)("makes no Management request for $argv", ({ kind, activeFlags }) => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, http } = setupCompute({ + workdir: repo.dir, + routes: { + [GET_COMPUTE_ROUTE]: { status: 200, body: { data: computeResource({ name: "api" }) } }, + }, + }); + + return yield* Effect.gen(function* () { + yield* computeLogs(flags({ tail: 0, kind })); + + expect(http.routeKeys).toEqual([]); + assertPermissionDrift({ + command: "compute logs", + variant: { computeEnabled: true }, + activeFlags, + requests: http.driftRequests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.effect("passes --tail through as the row limit", () => Effect.gen(function* () { const repo = yield* project(); @@ -440,7 +528,7 @@ describe("compute logs", () => { it.effect("says so when a deployed compute has simply been quiet", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, out } = setupCompute({ + const { layer, out, http } = setupCompute({ workdir: repo.dir, routes: { [LOGS_ROUTE]: logsResponse([]), @@ -452,6 +540,13 @@ describe("compute logs", () => { yield* computeLogs(flags()); expect(out.stdoutText).toContain('No logs for "api" in the last 24 hours.'); + expect(http.routeKeys).toEqual([LOGS_ROUTE, GET_COMPUTE_ROUTE]); + assertPermissionDrift({ + command: "compute logs", + variant: { computeEnabled: true }, + activeFlags: { follow: false }, + requests: http.driftRequests, + }); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -809,6 +904,14 @@ describe("compute logs", () => { DateTime.toEpochMillis(DateTime.makeUnsafe(start)), ).toBeLessThan(24 * 60 * 60 * 1000); } + expect(http.routeKeys).toEqual([LOGS_ROUTE, LOGS_ROUTE, LOGS_ROUTE, LOGS_ROUTE]); + assertPermissionDrift({ + command: "compute logs", + variant: { computeEnabled: true }, + activeFlags: { follow: true }, + requests: http.driftRequests, + exact: true, + }); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -931,6 +1034,32 @@ describe("compute logs", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.effect("checks the compute, then polls, when --tail 0 --follow finds no rows", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, http } = setupCompute({ + workdir: repo.dir, + routes: { + [GET_COMPUTE_ROUTE]: { status: 200, body: { data: computeResource({ name: "api" }) } }, + [LOGS_ROUTE]: logsResponse([]), + }, + }); + + return yield* Effect.gen(function* () { + yield* computeLogs(flags({ follow: true, tail: 0 }), followFor(0)); + + expect(http.routeKeys).toEqual([GET_COMPUTE_ROUTE, LOGS_ROUTE]); + assertPermissionDrift({ + command: "compute logs", + variant: { computeEnabled: true }, + activeFlags: { tail: "0", follow: true }, + requests: http.driftRequests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.effect("emits a log-entry event per line under stream-json", () => Effect.gen(function* () { yield* TestClock.setTime(T3); diff --git a/apps/cli/src/commands/experimental/compute/new/new.command.ts b/apps/cli/src/commands/experimental/compute/new/new.command.ts index e643e3977c..b749ef4760 100644 --- a/apps/cli/src/commands/experimental/compute/new/new.command.ts +++ b/apps/cli/src/commands/experimental/compute/new/new.command.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../../shared/runtime/command-runtime.layer.ts"; import { @@ -101,6 +102,10 @@ export const computeNewCommand = Command.make("new", config).pipe( description: "Scaffold the compute outside the compute directory", }, ]), + withPermissions({ + operations: [], + noApiEffectFlags: ["runtime", "size", "exposure", "instances", "source"], + }), Command.withHandler((flags) => computeNew(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md index 67f60895be..08425af48d 100644 --- a/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md @@ -21,25 +21,27 @@ and the command handler does not run. See the [Compute command guide](../../../. | `/supabase/config.json` | JSON | always when present — preferred over `config.toml`; each compute's runtime, size, exposure, instances, source. An explicit `--workdir`/`SUPABASE_WORKDIR` is read exactly as given, with no ancestor search; with a DEFAULTED workdir the loader may resolve an ancestor project's `config.json` from a subdirectory (CLI-2285), and the resolved compute `source` below resolves against that SAME ancestor root | | `/supabase/config.toml` | TOML | always when no `config.json` exists — the same compute fields, with the same explicit-vs-default workdir rule | | `/**` | any | always — packaged into the build context, minus whatever `[compute.] exclude` matches. An excluded directory is not descended into, so nothing under it is read at all | +| `/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | | `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | | `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | ## Files Written -| Path | Format | When | -| ----------------------------------------------- | ------ | --------------------------------------------------------------- | -| `/telemetry.json` | JSON | always — flushed on success and on failure | -| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | post-run, once the project ref has resolved (success or failure), only when no such file exists, `/supabase/.temp/project-ref` doesn't name another ref, an access token exists and `GET /v1/projects/{ref}` returns 200 | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------------------------------------- | ------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------ | -| `POST` | `/v2/projects/{ref}/compute/{name}/uploads` | Bearer token | none | `data.id`, `data.attributes.url/method` | -| `PUT` | presigned upload URL (control-plane storage) | URL signature — **no** Supabase credentials | `.tar.gz` build context | status only | -| `POST` | `/v2/projects/{ref}/compute/{name}/deploy` | Bearer token | `{data:{type,attributes:{spec,context_upload_id}}}` | `data.attributes.build_state` | -| `GET` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none | `build_state`, `state_reason`, `image_version`, `spec` | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache miss only — name, org, region | +| Method | Path | Auth | Request body | Response (used fields) | +| --------------- | -------------------------------------------- | ------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST` | `/v2/projects/{ref}/compute/{name}/uploads` | Bearer token | none | `data.id`, `data.attributes.url/method` | +| `POST` or `PUT` | presigned upload URL (control-plane storage) | URL signature — **no** Supabase credentials | `.tar.gz` build context | status only. The method is the one the upload slot names: `POST` if it says so, else `PUT` | +| `POST` | `/v2/projects/{ref}/compute/{name}/deploy` | Bearer token | `{data:{type,attributes:{spec,context_upload_id}}}` | `data.attributes.build_state` | +| `GET` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none | `build_state`, `state_reason`, `image_version`, `spec` | +| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — project picker: only when no ref resolves from `--project-ref`, `SUPABASE_PROJECT_ID` or `supabase/.temp/project-ref` and stdin and stdout are TTYs | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | `GET /v2/projects/{ref}/compute/{name}` is polled until `build_state` leaves `building`. It is skipped entirely in two cases: under `--no-wait`, and when @@ -70,6 +72,7 @@ run reports the accepted spec the deploy response returned. | ------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SUPABASE_EXPERIMENTAL_COMPUTE` | command registration (`1` enable, `0` disable; unset/empty uses `experimental.compute`; other non-empty values are errors) | no | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | | `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | | `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) — read exactly as given when SET (flag or env), with **no ancestor search**; a DEFAULTED workdir may still resolve an ancestor project's config from a subdirectory (CLI-2285) | | `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | @@ -170,6 +173,6 @@ An excluded symlink is excluded **before** it is vetted for escaping the build c adding a hoisted `node_modules` link to `exclude` is a real answer to `ComputeSourceEscapingLinkError` rather than something that failure pre-empts. -The presigned `PUT` above is the one request whose URL is itself a credential. +The presigned upload above is the one request whose URL is itself a credential. `--debug` logs every request URL, so `httpClientLayer` redacts query strings that carry a signature. diff --git a/apps/cli/src/commands/experimental/compute/push/push.command.ts b/apps/cli/src/commands/experimental/compute/push/push.command.ts index a2d833d080..1e24fd4ad5 100644 --- a/apps/cli/src/commands/experimental/compute/push/push.command.ts +++ b/apps/cli/src/commands/experimental/compute/push/push.command.ts @@ -1,5 +1,14 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withContext, +} from "../../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../../telemetry/linked-project-cache.layer.ts"; +import { computeRead } from "../../../../shared/compute/compute-api.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { COMPUTE_EXPOSURES } from "../../../../shared/compute/compute-runtimes.ts"; import { managementApiRuntimeLayer } from "../../../../command-internal/management-api-runtime.layer.ts"; @@ -80,6 +89,23 @@ export const computePushCommand = Command.make("push", config).pipe( description: "Deploy without blocking on the build", }, ]), + withPermissions( + compose( + { + operations: [ + { operationId: "v2-create-compute-instance-upload", kind: "required" }, + { operationId: "v2-deploy-a-compute-instance", kind: "required" }, + ], + noApiEffectFlags: ["instances", "exposure"], + }, + withContext( + withCondition(computeRead, { flag: "no-wait", is: false }), + "the deploy response reports the build as still building", + ), + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => computePush(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts b/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts index 1cc0e1484f..f58367a2e8 100644 --- a/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts @@ -9,6 +9,8 @@ import { COMPUTE_PROJECT_REF, type ComputeHttpRoutes, } from "../../../../../tests/helpers/compute.ts"; +import { assertPermissionDrift } from "../../../../../tests/helpers/permission-drift.ts"; +import { projectResponse } from "../../../../../tests/helpers/permission-real-layers.ts"; import { ProjectRefNotLinkedError } from "../../../../config/project-ref.errors.ts"; import { ComputeEnvNotSupportedError } from "../compute.errors.ts"; import { @@ -133,6 +135,13 @@ describe("compute push", () => { `POST ${computeRoute("/api/deploy")}`, `GET ${computeRoute("/api")}`, ]); + assertPermissionDrift({ + command: "compute push", + variant: { computeEnabled: true }, + activeFlags: { "no-wait": false }, + requests: http.driftRequests, + exact: true, + }); const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); expect(yield* decodeDeploy(deploy?.body ?? "{}")).toEqual({ @@ -178,6 +187,24 @@ describe("compute push", () => { "PUT /deploy-context/api.tar.gz", `POST ${computeRoute("/api/deploy")}`, ]); + assertPermissionDrift({ + command: "compute push", + variant: { computeEnabled: true }, + activeFlags: { "no-wait": true }, + requests: http.driftRequests, + exact: true, + }); + expect(() => + assertPermissionDrift({ + command: "compute push", + variant: { computeEnabled: true }, + activeFlags: { "no-wait": true }, + requests: [ + ...http.driftRequests, + { method: "GET", url: `https://api.supabase.com${computeRoute("/api")}` }, + ], + }), + ).toThrow(/condition doesn't hold/); expect(out.stdoutText).toContain("Deployed Compute api"); // No image exists yet, so the row is dropped rather than rendered empty. @@ -187,6 +214,48 @@ describe("compute push", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.effect("picks the project, deploys the compute and records the linked project", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, out, http } = setupCompute({ + workdir: repo.dir, + realLayers: true, + interactive: true, + promptSelectResponses: [COMPUTE_PROJECT_REF], + routes: routes({ + "GET /v1/projects": { status: 200, body: [projectResponse(COMPUTE_PROJECT_REF)] }, + [`GET /v1/projects/${COMPUTE_PROJECT_REF}`]: { + status: 200, + body: projectResponse(COMPUTE_PROJECT_REF), + }, + }), + }); + + return yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* push({ noWait: true }); + + expect(http.routeKeys).toEqual([ + "GET /v1/projects", + `POST ${computeRoute("/api/uploads")}`, + "PUT /deploy-context/api.tar.gz", + `POST ${computeRoute("/api/deploy")}`, + `GET /v1/projects/${COMPUTE_PROJECT_REF}`, + ]); + expect(out.promptSelectCalls).toHaveLength(1); + expect(out.stdoutText).toContain("Deployed Compute api"); + expect(yield* fs.exists(`${repo.dir}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "compute push", + variant: { computeEnabled: true }, + activeFlags: { "no-wait": true }, + requests: http.driftRequests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + // `V2DeployAWorkerOutput` permits a terminal state on the deploy response // itself, and that verdict is this deploy's. A poll on top of it can only // contradict it — `awaitComputeBuild` reads a post-deploy 404 as "still diff --git a/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md index 9c420b5695..5082692c71 100644 --- a/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md @@ -28,17 +28,18 @@ and the command handler does not run. See the [Compute command guide](../../../. ## Files Written -| Path | Format | When | -| ----------------------------------------------- | ------ | --------------------------------------------------------------- | -| `/telemetry.json` | JSON | always — flushed on success and on failure | -| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | post-run, once the project ref has resolved (success or failure), only when no such file exists, `/supabase/.temp/project-ref` doesn't name another ref, an access token exists and `GET /v1/projects/{ref}` returns 200 | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none | `spec`, `build_state`, `state_reason`, `image_version`, `instances`, `instances_error`, `deleting` | -| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------- | ------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none | `spec`, `build_state`, `state_reason`, `image_version`, `instances`, `instances_error`, `deleting` | +| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | ## Exit Codes diff --git a/apps/cli/src/commands/experimental/compute/status/status.command.ts b/apps/cli/src/commands/experimental/compute/status/status.command.ts index 692669787a..97f7a756b2 100644 --- a/apps/cli/src/commands/experimental/compute/status/status.command.ts +++ b/apps/cli/src/commands/experimental/compute/status/status.command.ts @@ -1,5 +1,10 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../../telemetry/linked-project-cache.layer.ts"; +import { computeRead } from "../../../../shared/compute/compute-api.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; @@ -26,6 +31,7 @@ export const computeStatusCommand = Command.make("status", config).pipe( description: "Inspect a specific compute", }, ]), + withPermissions(compose(computeRead, projectRefPicker, linkedProjectCache)), Command.withHandler((flags) => computeStatus(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts b/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts index 2371e5a892..bbc6b3a463 100644 --- a/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Option, Path } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; import { makeComputeProject, setupCompute, @@ -8,6 +8,12 @@ import { computeRoute, COMPUTE_PROJECT_REF, } from "../../../../../tests/helpers/compute.ts"; +import { + assertBlockDrift, + assertPermissionDrift, +} from "../../../../../tests/helpers/permission-drift.ts"; +import { projectResponse } from "../../../../../tests/helpers/permission-real-layers.ts"; +import { computeRead } from "../../../../shared/compute/compute-api.ts"; import { InvalidComputeNameError, ComputeNotDeployedError, @@ -35,7 +41,7 @@ describe("compute status", () => { Effect.gen(function* () { const path = yield* Path.Path; const repo = yield* project(); - const { layer, out } = setupCompute({ + const { layer, out, http } = setupCompute({ workdir: repo.dir, routes: { [getRoute]: { @@ -67,6 +73,61 @@ describe("compute status", () => { expect(stdout).toContain("2/3 ready, 3 live, 1 stale"); expect(stdout).toContain(`https://${COMPUTE_PROJECT_REF}.supabase.co/compute/v1/api`); expect(stdout).toContain(path.join("supabase", "compute", "api")); + expect(http.routeKeys).toEqual([getRoute]); + assertPermissionDrift({ + command: "compute status", + variant: { computeEnabled: true }, + requests: http.driftRequests, + exact: true, + }); + assertBlockDrift(computeRead, { + requests: http.driftRequests.filter( + (request) => new URL(request.url).pathname === computeRoute("/api"), + ), + }); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect("picks the project, reads the compute and records the linked project", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, out, http } = setupCompute({ + workdir: repo.dir, + realLayers: true, + interactive: true, + promptSelectResponses: [COMPUTE_PROJECT_REF], + routes: { + "GET /v1/projects": { status: 200, body: [projectResponse(COMPUTE_PROJECT_REF)] }, + [`GET /v1/projects/${COMPUTE_PROJECT_REF}`]: { + status: 200, + body: projectResponse(COMPUTE_PROJECT_REF), + }, + [getRoute]: { + status: 200, + body: { data: computeResource({ name: "api", runtime: "node", imageVersion: "v3" }) }, + }, + }, + }); + + return yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* computeStatus({ name: "api", projectRef: Option.none() }); + + expect(http.routeKeys).toEqual([ + "GET /v1/projects", + getRoute, + `GET /v1/projects/${COMPUTE_PROJECT_REF}`, + ]); + expect(out.promptSelectCalls).toHaveLength(1); + expect(out.stdoutText).toContain("v3"); + expect(yield* fs.exists(`${repo.dir}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "compute status", + variant: { computeEnabled: true }, + requests: http.driftRequests, + exact: true, + }); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -265,7 +326,7 @@ describe("compute status", () => { it.effect("fails with `not deployed` and points at push", () => Effect.gen(function* () { const repo = yield* project(); - const { layer } = setupCompute({ + const { layer, http } = setupCompute({ workdir: repo.dir, routes: { [getRoute]: { @@ -286,6 +347,12 @@ describe("compute status", () => { expect(error).toBeInstanceOf(ComputeNotDeployedError); const suggestion = error instanceof ComputeNotDeployedError ? error.suggestion : ""; expect(suggestion).toContain("supabase compute push api"); + expect(http.routeKeys).toEqual([getRoute]); + assertPermissionDrift({ + command: "compute status", + variant: { computeEnabled: true }, + requests: http.driftRequests, + }); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); diff --git a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md index 5ca78284ff..1985a4cd7b 100644 --- a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md @@ -12,16 +12,16 @@ ## Files Written -| Path | Format | When | -| ----------------------------------------------- | ------ | ----------------------------------------------------------------------- | -| `/supabase/.temp/linked-project.json` | JSON | after resolving a project ref, cached on both success and failure paths | -| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | +| Path | Format | When | +| ----------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | post-run, once a project ref has resolved (success or failure), only when no such file exists, `/supabase/.temp/project-ref` doesn't name another ref, an access token exists and `GET /v1/projects/{ref}` returns 200 | +| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | ## API Routes | Method | Path | Auth | Request body | Response (used fields) | | -------- | ------------------------------------- | ------------ | ------------ | ----------------------------------------------------- | -| `DELETE` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | none | +| `DELETE` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | none (a `404` fails with `FunctionNotFoundError`) | | `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | | `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | @@ -71,4 +71,4 @@ Prints a structured success result with the function slug and project ref. - Does NOT remove the function from the local filesystem. - Requires a linked project (`--project-ref` or linked project config). - Runs natively in TypeScript through the Management API. -- Refreshes the linked-project telemetry cache and flushes telemetry state after resolving a project ref. +- Telemetry state is flushed on success and failure. The linked-project cache is filled post-run only under the conditions listed in Files Written. diff --git a/apps/cli/src/commands/functions/delete/delete.command.ts b/apps/cli/src/commands/functions/delete/delete.command.ts index b37b234ae6..9157344f5b 100644 --- a/apps/cli/src/commands/functions/delete/delete.command.ts +++ b/apps/cli/src/commands/functions/delete/delete.command.ts @@ -1,5 +1,9 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; @@ -41,6 +45,13 @@ export const functionsDeleteCommand = Command.make("delete", config).pipe( description: "Delete a deployed function from a specific project", }, ]), + withPermissions( + compose( + { operations: [{ operationId: "v1-delete-a-function", kind: "required" }] }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler(functionsDeleteHandler), Command.provide(managementApiRuntimeLayer(["functions", "delete"])), ); diff --git a/apps/cli/src/commands/functions/delete/delete.integration.test.ts b/apps/cli/src/commands/functions/delete/delete.integration.test.ts index 11299a7f57..64af28e844 100644 --- a/apps/cli/src/commands/functions/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/functions/delete/delete.integration.test.ts @@ -1,21 +1,30 @@ import { describe, expect, it } from "@effect/vitest"; import { BunCrypto } from "@effect/platform-bun"; -import { Effect, Layer, Option, Stdio } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema, Stdio } from "effect"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { stripControlSequences } from "../../../shared/output/strip-control-sequences.ts"; import { + VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; -import { mockContextualAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockContextualAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { functionsDeleteHandler } from "./delete.command.ts"; import { functionsDelete } from "./delete.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-functions-delete-"); describe("functions delete", () => { @@ -48,6 +57,11 @@ describe("functions delete", () => { ); expect(linkedProjectCache.cached).toBe(true); expect(telemetry.flushed).toBe(true); + assertPermissionDrift({ + command: "functions delete", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -70,9 +84,89 @@ describe("functions delete", () => { }); expect(api.requests[0]?.url).toContain("/projects/qrstuvwxyzabcdefghij/functions/"); + assertPermissionDrift({ + command: "functions delete", + activeFlags: { "project-ref": "qrstuvwxyzabcdefghij" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); + it.effect("picks a project interactively and fills the linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.url === "https://api.supabase.com/v1/projects" + ? jsonResponse(request, 200, [projectResponse(VALID_REF)]) + : request.url === `https://api.supabase.com/v1/projects/${VALID_REF}` + ? jsonResponse(request, 200, projectResponse(VALID_REF)) + : jsonResponse(request, 200, null), + ), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true, stdoutIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* functionsDelete({ functionName: "hello-world", projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/functions/hello-world`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(stripControlSequences(out.stdoutText)).toBe( + `Deleted Function hello-world from project ${VALID_REF}.\n`, + ); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "functions delete", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect.each(["1leading-digit", "has space", "dot.name"])( + "rejects the invalid slug %j before any Management API request", + (functionName) => { + const api = mockCommandPlatformApi({ response: { status: 200, body: null } }); + const linkedProjectCache = mockLinkedProjectCacheTracked(); + const layer = buildTestRuntime({ + out: mockOutput({ format: "text" }), + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + linkedProjectCache: linkedProjectCache.layer, + }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + functionsDelete({ functionName, projectRef: Option.none() }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("InvalidFunctionSlugError"); + } + expect(api.requests).toEqual([]); + expect(linkedProjectCache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect( "does not redact --project-ref in cli_command_executed (Go parity: cmd/functions.go:153)", () => { diff --git a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md index 854be67649..556ee104b0 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -37,14 +37,16 @@ Docker bundling may pull or run the configured edge-runtime image and uses the ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ------------------------------------- | ------------ | ----------------------- | ---------------------- | -| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | `[{ slug, ... }]` | -| `POST` | `/v1/projects/{ref}/functions/deploy` | Bearer token | multipart source upload | `{ id, slug, ... }` | -| `POST` | `/v1/projects/{ref}/functions` | Bearer token | bundled function body | `{ id, slug, ... }` | -| `PATCH` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | bundled function body | `{ id, slug, ... }` | -| `PUT` | `/v1/projects/{ref}/functions` | Bearer token | bulk update payload | `{ functions: [...] }` | -| `DELETE` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | ignored on `200/404` | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------- | ------------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | `[{ slug, ... }]` | +| `POST` | `/v1/projects/{ref}/functions/deploy` | Bearer token | multipart source upload | `{ id, slug, ... }` | +| `POST` | `/v1/projects/{ref}/functions` | Bearer token | bundled function body | `{ id, slug, ... }` | +| `PATCH` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | bundled function body | `{ id, slug, ... }` | +| `PUT` | `/v1/projects/{ref}/functions` | Bearer token | bulk update payload | none (only status `200` is checked) | +| `DELETE` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | ignored on `200/404` | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `supabase/.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | ## Environment Variables diff --git a/apps/cli/src/commands/functions/deploy/deploy.command.ts b/apps/cli/src/commands/functions/deploy/deploy.command.ts index 1871002573..eb80c99408 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.command.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.command.ts @@ -1,6 +1,10 @@ import { Layer } from "effect"; import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; @@ -56,6 +60,11 @@ const config = { export type FunctionsDeployFlags = CliCommand.Command.Config.Infer; +const dockerPath = [ + { flag: "use-api", is: false }, + { flag: "use-docker", is: true }, +] as const; + export const functionsDeployCommand = Command.make("deploy", config).pipe( Command.withDescription("Deploy a Function to the linked Supabase project."), Command.withShortDescription("Deploy a Function to Supabase"), @@ -69,6 +78,77 @@ export const functionsDeployCommand = Command.make("deploy", config).pipe( description: "Deploy all local functions to a specific project", }, ]), + withPermissions( + compose( + { + operations: [ + { + operationId: "v1-deploy-a-function", + kind: "required", + when: [{ flag: "use-api", is: true }], + }, + { + operationId: "v1-deploy-a-function", + kind: "required", + when: [{ flag: "use-docker", is: false }], + }, + { + operationId: "v1-deploy-a-function", + kind: "required", + when: [...dockerPath], + context: "Docker isn't running (deploy falls back to API bundling)", + }, + { + operationId: "v1-list-all-functions", + kind: "required", + when: [...dockerPath], + context: "Docker is running", + }, + { + operationId: "v1-list-all-functions", + kind: "required", + when: [{ flag: "no-verify-jwt", is: false }], + context: + "on the API path (--use-api, --use-docker=false, or Docker not running), when an enabled function has no verify_jwt in config.toml", + }, + { + operationId: "v1-list-all-functions", + kind: "required", + when: [{ flag: "prune", is: true }], + }, + { + operationId: "v1-create-a-function", + kind: "required", + when: [...dockerPath], + context: + "Docker is running, a bundle differs from the deployed copy, and that function isn't deployed yet", + }, + { + operationId: "v1-update-a-function", + kind: "required", + when: [...dockerPath], + context: + "Docker is running, a bundle differs from the deployed copy, and that function is already deployed (or a create answered with a duplicated slug)", + }, + { + operationId: "v1-bulk-update-functions", + kind: "required", + context: + "two or more functions change (API path: two or more enabled functions with at least one accepted upload; Docker path: two or more changed bundles)", + }, + { + operationId: "v1-delete-a-function", + kind: "required", + when: [{ flag: "prune", is: true }], + context: "a deployed function has no local counterpart", + }, + ], + noApiEffectFlags: ["import-map", "jobs", "legacy-bundle"], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => functionsDeploy(flags).pipe( withCommandTelemetry({ flags, safeFlags: FUNCTIONS_PROJECT_REF_SAFE_FLAGS }), diff --git a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts index 319a8939f5..d45c6cc4e0 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -11,6 +11,7 @@ import { Option, Path, Predicate, + Schema, Stdio, } from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; @@ -19,6 +20,7 @@ import { YesFlag } from "../../../command-internal/global-flags.ts"; import { stripControlSequences } from "../../../shared/output/strip-control-sequences.ts"; import { type ApiHandler, + VALID_REF, buildTestRuntime, jsonResponse, mockCommandSettings, @@ -29,7 +31,12 @@ import { withConfigEnv, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; -import { mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockRuntimeInfo, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { mockChildProcessSpawner } from "../../../../tests/helpers/child-process-spawner.ts"; import { @@ -50,8 +57,36 @@ import { CommandPlatformApi } from "../../../auth/command-platform-api.service.t import { functionsDeploy } from "./deploy.handler.ts"; import type { FunctionsDeployFlags } from "./deploy.command.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-functions-deploy-legacy-"); +const apiPathFlags = { + "use-api": true, + "use-docker": true, + prune: false, + "no-verify-jwt": false, +} as const; + +const dockerPathFlags = { ...apiPathFlags, "use-api": false } as const; + +const deployedFunction = (slug: string) => ({ + id: `${slug}-id`, + slug, + name: slug, + status: "ACTIVE", + version: 2, + created_at: 1_687_423_025_152, + updated_at: 1_687_423_025_152, + verify_jwt: true, + import_map: true, + entrypoint_path: `functions/${slug}/index.ts`, + import_map_path: `functions/${slug}/deno.json`, +}); + +const requestLines = (requests: ReadonlyArray<{ readonly method: string; readonly url: string }>) => + requests.map(({ method, url }) => `${method} ${url}`); + const baseFlags: FunctionsDeployFlags = { functionNames: ["hello-world"], projectRef: Option.none(), @@ -189,6 +224,147 @@ describe("functions deploy", () => { ); expect(linkedProjectCache.cached).toBe(true); expect(telemetry.flushed).toBe(true); + expect(requestLines(api.requests)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `POST https://api.supabase.com/v1/projects/${VALID_REF}/functions/deploy`, + ]); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: apiPathFlags, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.effect("picks a project interactively and fills the linked-project cache on --use-api", () => { + const out = mockOutput({ format: "text", promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.url === "https://api.supabase.com/v1/projects" + ? jsonResponse(request, 200, [projectResponse(VALID_REF)]) + : request.url === `https://api.supabase.com/v1/projects/${VALID_REF}` + ? jsonResponse(request, 200, projectResponse(VALID_REF)) + : request.method === "GET" + ? jsonResponse(request, 200, []) + : jsonResponse(request, 201, deployedFunction("hello-world")), + ), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + tty: mockTty({ stdinIsTty: true, stdoutIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }), + Layer.succeed(YesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api"]), + }), + ); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* functionsDeploy(baseFlags); + + expect(requestLines(api.requests)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `POST https://api.supabase.com/v1/projects/${VALID_REF}/functions/deploy`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(stripControlSequences(out.stdoutText)).toContain( + `Deployed Functions on project ${VALID_REF}: hello-world\n`, + ); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: apiPathFlags, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.effect("skips the function list on --use-api --no-verify-jwt", () => { + const { api, layer } = apiDeployHarness(deployedHelloWorld, [ + "functions", + "deploy", + "hello-world", + "--use-api", + "--no-verify-jwt", + ]); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + yield* functionsDeploy({ ...baseFlags, noVerifyJwt: true }); + + expect(requestLines(api.requests)).toEqual([ + `POST https://api.supabase.com/v1/projects/${VALID_REF}/functions/deploy`, + ]); + const flags = { ...apiPathFlags, "no-verify-jwt": true }; + assertPermissionDrift({ + command: "functions deploy", + activeFlags: flags, + requests: api.requests, + exact: true, + }); + expect(() => + assertPermissionDrift({ + command: "functions deploy", + activeFlags: flags, + requests: [ + ...api.requests, + { method: "GET", url: `https://api.supabase.com/v1/projects/${VALID_REF}/functions` }, + ], + }), + ).toThrow(/condition doesn't hold/); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.effect("deploys through the API when --use-docker=false is passed alone", () => { + const { api, layer } = apiDeployHarness(deployedHelloWorld, [ + "functions", + "deploy", + "hello-world", + "--use-docker=false", + ]); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + yield* functionsDeploy({ ...baseFlags, useApi: false, useDocker: false }); + + expect(requestLines(api.requests)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `POST https://api.supabase.com/v1/projects/${VALID_REF}/functions/deploy`, + ]); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: { ...apiPathFlags, "use-api": false, "use-docker": false }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); @@ -578,7 +754,10 @@ describe("functions deploy", () => { }, ); - function apiDeployHarness(handler: ApiHandler) { + function apiDeployHarness( + handler: ApiHandler, + args: ReadonlyArray = ["functions", "deploy", "hello-world", "--use-api"], + ) { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ handler }); const layer = Layer.mergeAll( @@ -589,9 +768,7 @@ describe("functions deploy", () => { runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), Layer.succeed(YesFlag, false), - Stdio.layerTest({ - args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api"]), - }), + Stdio.layerTest({ args: Effect.succeed(args) }), ); return { out, api, layer }; } @@ -882,6 +1059,79 @@ describe("functions deploy", () => { decode: true, message: "failed to read function response: Expected a valid JSON string", }); + expect(requestLines(api.requests)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `POST https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + ]); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: dockerPathFlags, + requests: api.requests, + }); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }, + ); + + it.live( + "creates an undeployed function, updates a deployed one and bulk-updates both via Docker", + () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.method === "GET") { + return Effect.succeed( + jsonResponse(request, 200, [ + { ...deployedFunction("bye-world"), ezbr_sha256: "stale-digest" }, + ]), + ); + } + if (request.method === "PUT") { + return Effect.succeed(jsonResponse(request, 200, {})); + } + const slug = request.url.endsWith("/functions/bye-world") ? "bye-world" : "hello-world"; + return Effect.succeed( + jsonResponse(request, request.method === "POST" ? 201 : 200, deployedFunction(slug)), + ); + }, + }); + const child = mockDockerBundleSpawner(); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(YesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "bye-world"]), + }), + ); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + yield* writeLocalFunction(tempRoot.current, "bye-world"); + + yield* functionsDeploy({ + ...baseFlags, + functionNames: ["hello-world", "bye-world"], + useApi: false, + useDocker: true, + }); + + expect(requestLines(api.requests)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `POST https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `PATCH https://api.supabase.com/v1/projects/${VALID_REF}/functions/bye-world`, + `PUT https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + ]); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: dockerPathFlags, + requests: api.requests, + }); }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }, ); @@ -1093,6 +1343,74 @@ describe("functions deploy", () => { "Do you want to delete the following Functions from your project?\n • remote-only\n\n [y/N] y\n", ); expect(api.requests.some((request) => request.method === "DELETE")).toBe(true); + expect(requestLines(api.requests)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `POST https://api.supabase.com/v1/projects/${VALID_REF}/functions/deploy`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/functions/remote-only`, + ]); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: { ...apiPathFlags, prune: true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.effect("lists remote functions only for --prune when --no-verify-jwt is passed", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.method === "POST") { + return Effect.succeed(jsonResponse(request, 201, deployedFunction("hello-world"))); + } + if (request.method === "GET") { + return Effect.succeed( + jsonResponse(request, 200, [{ ...deployedFunction("remote-only"), import_map: false }]), + ); + } + return Effect.succeed(jsonResponse(request, 200, {})); + }, + }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(YesFlag, true), + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "deploy", + "hello-world", + "--use-api", + "--no-verify-jwt", + "--prune", + "--yes", + ]), + }), + ); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + yield* functionsDeploy({ ...baseFlags, noVerifyJwt: true, prune: true }); + + expect(requestLines(api.requests)).toEqual([ + `POST https://api.supabase.com/v1/projects/${VALID_REF}/functions/deploy`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/functions/remote-only`, + ]); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: { ...apiPathFlags, "no-verify-jwt": true, prune: true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); @@ -1191,6 +1509,11 @@ describe("functions deploy", () => { const bulkUpdate = api.requests.find((request) => request.method === "PUT"); expect(bulkUpdate?.body).toMatchObject([{ slug: "hello-world" }]); expect(out.stdoutText).not.toContain("Deployed Functions on project"); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: apiPathFlags, + requests: api.requests, + }); }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); @@ -1213,8 +1536,18 @@ describe("functions deploy", () => { 'unexpected deploy status 400: {"message":"rejected bye-world"}', ].join("\n"), }); + expect( + api.requests.filter( + (request) => request.method === "POST" && request.url.endsWith("/functions/deploy"), + ), + ).toHaveLength(2); expect(api.requests.some((request) => request.method === "PUT")).toBe(false); expect(out.stdoutText).not.toContain("Deployed Functions on project"); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: apiPathFlags, + requests: api.requests, + }); }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); @@ -1266,6 +1599,11 @@ describe("functions deploy", () => { ].join("\n"), }); expect(api.requests.filter((request) => request.method === "PUT")).toHaveLength(1); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: apiPathFlags, + requests: api.requests, + }); }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); }); @@ -1522,6 +1860,15 @@ describe("functions deploy", () => { expect(stripControlSequences(out.stdoutText)).toContain( "Deployed Functions on project abcdefghijklmnopqrst: hello-world\n", ); + expect(requestLines(api.requests)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `POST https://api.supabase.com/v1/projects/${VALID_REF}/functions/deploy`, + ]); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: dockerPathFlags, + requests: api.requests, + }); }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); }); diff --git a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md index 94c7037995..b8570af678 100644 --- a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md @@ -16,22 +16,22 @@ ## Files Written -| Path | Format | When | -| --------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/functions//` | bytes | for each source file returned by the API (`--use-api`, or the Docker-unbundle fallback when Docker isn't running) | -| `/supabase/.temp/output_.eszip` | bytes | Docker-unbundle path (default): downloaded eszip, extracted into `supabase/functions//...` by the edge-runtime container; removed after the attempt unless `--debug` is set | -| `/supabase/.temp/linked-project.json` | JSON | after resolving a project ref, cached on both success and failure paths | -| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | +| Path | Format | When | +| --------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/functions//` | bytes | for each source file returned by the API (server-side path: `--use-api`, `--use-docker=false`, or the Docker-unbundle fallback when Docker isn't running) | +| `/supabase/.temp/output_.eszip` | bytes | Docker-unbundle path (default): downloaded eszip, extracted into `supabase/functions//...` by the edge-runtime container; removed after the attempt unless `--debug` is set | +| `/supabase/.temp/linked-project.json` | JSON | post-run, once a project ref has resolved (success or failure), only when no such file exists, `/supabase/.temp/project-ref` doesn't name another ref, an access token exists and `GET /v1/projects/{ref}` returns 200 | +| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------ | ------------ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | function slugs, when downloading all | -| `GET` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | entrypoint path, when absent from multipart metadata (`--use-api` path only) | -| `GET` | `/v1/projects/{ref}/functions/{slug}/body` | Bearer token | none | `--use-api`: multipart function source (`Accept: multipart/form-data`). Docker-unbundle: raw eszip bytes; a `Content-Encoding: br` response is decoded transparently by the HTTP transport, not by this command | -| `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------ | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | function slugs, when downloading all | +| `GET` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | entrypoint path, when absent from multipart metadata (server-side path only: `--use-api`, `--use-docker=false`, or Docker not running) | +| `GET` | `/v1/projects/{ref}/functions/{slug}/body` | Bearer token | none | Server-side path: multipart function source (`Accept: multipart/form-data`). Docker-unbundle: raw eszip bytes; a `Content-Encoding: br` response is decoded transparently by the HTTP transport, not by this command | +| `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | ## Subprocesses @@ -136,5 +136,7 @@ Same envelope as `json` above (including on the proxy and Docker-unbundle paths) not `--use-docker`'s default value — so `--use-api` alone never trips the "mutually exclusive" error. The `--legacy-bundle` Go proxy call itself only ever forwards `--legacy-bundle`, never `--use-docker` alongside it, even though `--use-docker` defaults to `true`. -- Refreshes the linked-project telemetry cache and flushes telemetry state after resolving a - project ref. +- Telemetry state is flushed on success and failure. The linked-project cache is filled post-run + only under the conditions listed in Files Written. +- With `--legacy-bundle` the Go binary makes its own list, get and body calls. In machine-output + modes with no function name, the TS side lists first, before delegating. diff --git a/apps/cli/src/commands/functions/download/download.command.ts b/apps/cli/src/commands/functions/download/download.command.ts index 55c0c8d23a..5e22ea65c0 100644 --- a/apps/cli/src/commands/functions/download/download.command.ts +++ b/apps/cli/src/commands/functions/download/download.command.ts @@ -1,5 +1,10 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { functionsDownload as functionsDownloadCalls } from "../../../shared/functions/download.ts"; import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; @@ -56,6 +61,17 @@ export const functionsDownloadCommand = Command.make("download", config).pipe( description: "Download all functions from a specific project", }, ]), + withPermissions( + compose( + { + operations: [], + noApiEffectFlags: ["use-api", "use-docker", "legacy-bundle"], + }, + functionsDownloadCalls, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler(functionsDownloadHandler), Command.provide(managementApiRuntimeLayer(["functions", "download"])), ); diff --git a/apps/cli/src/commands/functions/download/download.integration.test.ts b/apps/cli/src/commands/functions/download/download.integration.test.ts index b440222d12..df0461afb9 100644 --- a/apps/cli/src/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/commands/functions/download/download.integration.test.ts @@ -13,6 +13,7 @@ import { Option, Path, PlatformError, + Schema, Sink, Stdio, Stream, @@ -33,11 +34,22 @@ import { withConfigEnv, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; -import { mockContextualAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockContextualAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { + assertBlockDrift, + assertPermissionDrift, +} from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { mockChildProcessSpawner } from "../../../../tests/helpers/child-process-spawner.ts"; import { GoProxy } from "../../../command-internal/go-proxy.service.ts"; import { containerRuntimeNotFoundMessage } from "../../../command-internal/container-cli.ts"; -import { downloadFunctions } from "../../../shared/functions/download.ts"; +import { + downloadFunctions, + functionsDownload as functionsDownloadCalls, +} from "../../../shared/functions/download.ts"; import { functionsGoConfigCompat } from "../../../command-internal/functions-go-config.ts"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { ConflictingFunctionDownloadFlagsError } from "../../../shared/functions/download.errors.ts"; @@ -48,6 +60,8 @@ import { functionsDownload } from "./download.handler.ts"; const PROJECT_ID = "abcdefghijklmnopqrst"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + /** * Sets the spawner's exit code/stdio only for the `docker run ... unbundle` * step; earlier Docker calls in the same test already resolved by the time @@ -241,6 +255,128 @@ describe("functions download", () => { ); expect(linkedProjectCache.cached).toBe(true); expect(telemetry.flushed).toBe(true); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${PROJECT_ID}/functions/hello-world/body`, + ]); + assertPermissionDrift({ command: "functions download", requests: api.requests }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("picks a project interactively and fills the linked-project cache on --use-api", () => { + const out = mockOutput({ format: "text", promptSelectResponses: [PROJECT_ID] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.url === "https://api.supabase.com/v1/projects" + ? jsonResponse(request, 200, [projectResponse(PROJECT_ID)]) + : request.url === `https://api.supabase.com/v1/projects/${PROJECT_ID}` + ? jsonResponse(request, 200, projectResponse(PROJECT_ID)) + : request.url.endsWith("/body") + ? multipartResponse(request) + : jsonResponse(request, 200, {}), + ), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true, stdoutIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }), + mockProxy().layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "download", "hello-world", "--use-api"]), + }), + ); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cachePath = path.join(tempRoot.current, "supabase", ".temp", "linked-project.json"); + expect(yield* fs.exists(cachePath)).toBe(false); + yield* functionsDownload({ ...baseFlags, useApi: true, useDocker: true }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${PROJECT_ID}/functions/hello-world/body`, + `GET https://api.supabase.com/v1/projects/${PROJECT_ID}`, + ]); + expect(out.stderrText).toContain( + `Downloaded Function hello-world from project ${PROJECT_ID}.`, + ); + expect( + yield* fs.readFileString( + path.join(tempRoot.current, "supabase", "functions", "hello-world", "index.ts"), + ), + ).toBe("console.log('legacy native')"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: PROJECT_ID, + }); + assertPermissionDrift({ + command: "functions download", + activeFlags: { "use-api": true }, + requests: api.requests, + }); + assertBlockDrift(functionsDownloadCalls, { + requests: api.requests.filter(({ url }) => url.includes("/functions")), + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fetches the function metadata when the downloaded body names no entrypoint", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => + request.url.endsWith("/body") + ? Effect.succeed(multipartResponse(request, { metadata: {} })) + : Effect.succeed(jsonResponse(request, 200, { entrypoint_path: "source/index.ts" })), + }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + }), + mockProxy().layer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-api", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* functionsDownload({ ...baseFlags, useApi: true, useDocker: true }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${PROJECT_ID}/functions/hello-world/body`, + `GET https://api.supabase.com/v1/projects/${PROJECT_ID}/functions/hello-world`, + ]); + expect( + yield* fs.readFileString( + path.join(tempRoot.current, "supabase", "functions", "hello-world", "index.ts"), + ), + ).toBe("console.log('legacy native')"); + assertPermissionDrift({ + command: "functions download", + activeFlags: { "use-api": true }, + requests: api.requests, + }); + assertBlockDrift(functionsDownloadCalls, { requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -514,9 +650,10 @@ describe("functions download", () => { expect(proxy.calls).toEqual([]); expect(proxy.captureCalls).toEqual([]); - expect(api.requests.some((request) => request.url.endsWith("/hello-world/body"))).toBe( - true, - ); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${PROJECT_ID}/functions/hello-world/body`, + ]); + assertPermissionDrift({ command: "functions download", requests: api.requests }); expect( child.spawned.some( (spawned) => spawned.command === "docker" && spawned.args[0] === "run", @@ -745,6 +882,12 @@ describe("functions download", () => { }, }), ); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${PROJECT_ID}/functions`, + `GET https://api.supabase.com/v1/projects/${PROJECT_ID}/functions/hello-world/body`, + `GET https://api.supabase.com/v1/projects/${PROJECT_ID}/functions/goodbye-world/body`, + ]); + assertPermissionDrift({ command: "functions download", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -1890,6 +2033,7 @@ describe("functions download", () => { ], ]); expect(proxy.envs).toEqual([{ SUPABASE_TELEMETRY_DISABLED: "1" }]); + expect(api.requests).toEqual([]); }).pipe(Effect.provide(layer)); }, ); diff --git a/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md index 78f2105743..93f554fbc4 100644 --- a/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md @@ -12,10 +12,10 @@ ## Files Written -| Path | Format | When | -| ----------------------------------------------- | ------ | ----------------------------------------------------------------------- | -| `/supabase/.temp/linked-project.json` | JSON | after resolving a project ref, cached on both success and failure paths | -| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | +| Path | Format | When | +| ----------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | post-run, once a project ref has resolved (success or failure), only when no such file exists, `/supabase/.temp/project-ref` doesn't name another ref, an access token exists and `GET /v1/projects/{ref}` returns 200 | +| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | ## API Routes diff --git a/apps/cli/src/commands/functions/list/list.command.ts b/apps/cli/src/commands/functions/list/list.command.ts index d83c55f542..da56d6ba8c 100644 --- a/apps/cli/src/commands/functions/list/list.command.ts +++ b/apps/cli/src/commands/functions/list/list.command.ts @@ -1,5 +1,9 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; @@ -28,6 +32,13 @@ export const functionsListCommand = Command.make("list", config).pipe( description: "List all deployed functions in a specific project", }, ]), + withPermissions( + compose( + { operations: [{ operationId: "v1-list-all-functions", kind: "required" }] }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => functionsList(flags).pipe( withCommandTelemetry({ flags, safeFlags: FUNCTIONS_PROJECT_REF_SAFE_FLAGS }), diff --git a/apps/cli/src/commands/functions/list/list.integration.test.ts b/apps/cli/src/commands/functions/list/list.integration.test.ts index f128786f23..e6a3e88df5 100644 --- a/apps/cli/src/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/commands/functions/list/list.integration.test.ts @@ -1,20 +1,26 @@ import type { V1ListAllFunctionsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema } from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { ProjectRefNotLinkedError } from "../../../config/project-ref.errors.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { functionsList } from "./list.handler.ts"; @@ -54,6 +60,8 @@ const UNKNOWN_STATUS_FUNCTION = { status: "PAUSED_FOR_REBALANCE", }; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-functions-list-int-"); interface SetupOpts { @@ -244,6 +252,75 @@ describe("functions list integration", () => { yield* functionsList({ projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/functions`); + assertPermissionDrift({ command: "functions list", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("picks a project interactively and fills the linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.url === "https://api.supabase.com/v1/projects" + ? jsonResponse(request, 200, [projectResponse(VALID_REF)]) + : request.url === `https://api.supabase.com/v1/projects/${VALID_REF}` + ? jsonResponse(request, 200, projectResponse(VALID_REF)) + : jsonResponse(request, 200, [SAMPLE_FUNCTION]), + ), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true, stdoutIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* functionsList({ projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain("Hello World"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "functions list", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("skips the project picker when --project-ref is empty and a ref file exists", () => { + const savedRef = "qrstuvwxyzabcdefghij"; + const out = mockOutput(); + const api = mockCommandPlatformApi({ response: { status: 200, body: [SAMPLE_FUNCTION] } }); + const layer = buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current, projectId: Option.none() }), + tty: mockTty({ stdinIsTty: true, stdoutIsTty: true }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${tempRoot.current}/supabase/.temp`, { recursive: true }); + yield* fs.writeFileString(`${tempRoot.current}/supabase/.temp/project-ref`, savedRef); + yield* functionsList({ projectRef: Option.some("") }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${savedRef}/functions`, + ]); + assertPermissionDrift({ + command: "functions list", + activeFlags: { "project-ref": "" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -260,11 +337,17 @@ describe("functions list integration", () => { return Effect.gen(function* () { yield* functionsList({ projectRef: Option.some("qrstuvwxyzabcdefghij") }); expect(api.requests[0]?.url).toContain("/v1/projects/qrstuvwxyzabcdefghij/functions"); + assertPermissionDrift({ + command: "functions list", + activeFlags: { "project-ref": "qrstuvwxyzabcdefghij" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); it.effect("fails with FunctionsListUnexpectedStatusError on HTTP 503", () => { - const { layer } = setup({ status: 503, response: [] }); + const { layer, api } = setup({ status: 503, response: [] }); return Effect.gen(function* () { const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); @@ -273,6 +356,10 @@ describe("functions list integration", () => { expect(causeText).toContain("FunctionsListUnexpectedStatusError"); expect(causeText).toContain("unexpected list functions status 503"); } + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + ]); + assertPermissionDrift({ command: "functions list", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/functions/new/new.command.ts b/apps/cli/src/commands/functions/new/new.command.ts index 61c913bace..b03d12834c 100644 --- a/apps/cli/src/commands/functions/new/new.command.ts +++ b/apps/cli/src/commands/functions/new/new.command.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; @@ -38,6 +39,7 @@ const functionsNewRuntimeLayer = Layer.mergeAll( export const functionsNewCommand = Command.make("new", config).pipe( Command.withDescription("Create a new Function locally."), Command.withShortDescription("Create a new Function locally"), + withPermissions({ operations: [], noApiEffectFlags: ["auth"] }), Command.withHandler((flags) => functionsNew(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/functions/serve/serve.command.ts b/apps/cli/src/commands/functions/serve/serve.command.ts index 095e34b795..79164d2d99 100644 --- a/apps/cli/src/commands/functions/serve/serve.command.ts +++ b/apps/cli/src/commands/functions/serve/serve.command.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { httpClientLayer } from "../../../auth/http-debug.layer.ts"; import { Argument, Command, Flag } from "effect/cli"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { FUNCTIONS_SERVE_INSPECT_MODES } from "../../../shared/functions/serve.ts"; @@ -68,6 +69,18 @@ const commandConfig = { export const functionsServeCommand = Command.make("serve", commandConfig).pipe( Command.withDescription("Serve all Functions locally."), Command.withShortDescription("Serve all Functions locally"), + withPermissions({ + operations: [], + noApiEffectFlags: [ + "no-verify-jwt", + "env-file", + "import-map", + "inspect", + "inspect-mode", + "inspect-main", + "all", + ], + }), Command.withHandler((flags) => functionsServe(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), diff --git a/apps/cli/src/shared/compute/compute-api.ts b/apps/cli/src/shared/compute/compute-api.ts index 64ce7b5f72..7ac9ea2100 100644 --- a/apps/cli/src/shared/compute/compute-api.ts +++ b/apps/cli/src/shared/compute/compute-api.ts @@ -7,6 +7,7 @@ import { type ApiClient, } from "@supabase/api/effect"; import { Effect, Option, Schedule } from "effect"; +import { block } from "../../command-internal/command-permissions/model.ts"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import { @@ -166,6 +167,11 @@ export const listCompute = Effect.fnUntraced(function* (api: ApiClient, projectR return decoded.data.map(toComputeRecord); }); +/** The Management API call `getCompute` makes. */ +export const computeRead = block("compute-read", [ + { operationId: "v2-get-a-compute-instance", kind: "required" }, +]); + /** One compute, or `None` when this project has no record of it — see the 404 notes above. */ export const getCompute = Effect.fnUntraced(function* ( api: ApiClient, @@ -216,7 +222,7 @@ export const createComputeUpload = Effect.fnUntraced(function* ( }); /** - * PUTs the archive straight at the presigned slot, with no Supabase credentials attached — the + * Sends the archive straight to the presigned slot (POST or PUT, as the slot says), with no Supabase credentials attached — the * signature in the URL is the authorization. That's also why `httpClientLayer` redacts query * strings before logging: under `--debug` this URL is a write-capable credential. */ diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index 6c24e10614..a8699b36d2 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -7,6 +7,7 @@ import * as HttpBody from "effect/http/HttpBody"; import * as HttpClientError from "effect/http/HttpClientError"; import type * as HttpClientResponse from "effect/http/HttpClientResponse"; import { Output } from "../output/output.service.ts"; +import { block } from "../../command-internal/command-permissions/model.ts"; import { cobraMutuallyExclusiveErrorMessage, explicitBooleanLongFlag, @@ -1180,6 +1181,22 @@ function attachDownloadWrittenSoFar( : Object.assign(error, { writtenSoFar: [...downloadedSoFar] }); } +/** The Management API calls `downloadFunctions` makes: the function body, its metadata and the function list. */ +export const functionsDownload = block("functions-download", [ + { operationId: "v1-get-a-function-body", kind: "required" }, + { + operationId: "v1-get-a-function", + kind: "required", + context: + "on the server-side path (--use-api, --use-docker=false, or Docker not running), when the downloaded metadata names no entrypoint", + }, + { + operationId: "v1-list-all-functions", + kind: "required", + context: "no function name is given", + }, +]); + export const downloadFunctions = Effect.fn("functions.download")(function* < ResolveError, ResolveRequirements, diff --git a/apps/cli/tests/helpers/compute.ts b/apps/cli/tests/helpers/compute.ts index 5b402562dd..434e2f8424 100644 --- a/apps/cli/tests/helpers/compute.ts +++ b/apps/cli/tests/helpers/compute.ts @@ -5,8 +5,10 @@ import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientError from "effect/http/HttpClientError"; import type * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { CommandPlatformApiFactory } from "../../src/auth/command-platform-api-factory.service.ts"; import { CommandPlatformApi } from "../../src/auth/command-platform-api.service.ts"; import { CommandSettings } from "../../src/config/command-settings.service.ts"; +import { projectRefLayer } from "../../src/config/project-ref.layer.ts"; import { ProjectRefResolver } from "../../src/config/project-ref.service.ts"; import { CliArgs } from "../../src/shared/cli/cli-args.service.ts"; import { OutputFlag, YesFlag } from "../../src/command-internal/global-flags.ts"; @@ -15,6 +17,7 @@ import { ProjectRefNotLinkedError } from "../../src/config/project-ref.errors.ts import { mockLinkedProjectCacheLayer } from "./command-mocks.ts"; import { TelemetryState } from "../../src/telemetry/telemetry-state.service.ts"; import { mockOutput, mockProcessControl, mockRuntimeInfo, mockTty } from "./mocks.ts"; +import { realLinkedProjectCache } from "./permission-real-layers.ts"; /** * Shared scaffolding for the `supabase compute` command integration tests. @@ -196,10 +199,19 @@ function mockComputeHttp(routes: ComputeHttpRoutes) { return { layer: Layer.mergeAll(apiLayer, httpClientLayer), + httpClientLayer, requests, get routeKeys(): Array { return requests.map((request) => `${request.method} ${new URL(request.url).pathname}`); }, + /** The requests in the shape the permission drift helpers take. */ + get driftRequests() { + return requests.map(({ method, url, urlParams }) => ({ + method, + url, + urlParams: new URLSearchParams(urlParams).toString(), + })); + }, }; } @@ -416,6 +428,7 @@ export interface ComputeSetupOptions { * delete api`. */ readonly stdinIsTty?: boolean; + /** Picks the mock resolver's linkage; ignored with `realLayers`, where the workdir's ref file decides. */ readonly linked?: boolean; readonly promptTextResponses?: ReadonlyArray; readonly promptSelectResponses?: ReadonlyArray; @@ -436,6 +449,12 @@ export interface ComputeSetupOptions { * never fires, modelling a terminal nobody has interrupted. */ readonly signal?: "SIGINT" | "SIGTERM" | "SIGHUP"; + /** + * Runs the real `projectRefLayer` (an unlinked workdir reaches its picker) and the real + * linked-project cache, so their `GET /v1/projects` and `GET /v1/projects/{ref}` calls go + * through `routes`. Linkage then comes from the workdir's `.temp/project-ref`, not `linked`. + */ + readonly realLayers?: boolean; } /** @@ -479,6 +498,26 @@ export function setupCompute(options: ComputeSetupOptions) { const processControl = mockProcessControl( options.signal === undefined ? {} : { signal: options.signal }, ); + const tty = mockTty({ stdinIsTty: options.stdinIsTty ?? interactive, stdoutIsTty: interactive }); + const cliSettings = testCliConfigLayer(options.workdir, options.explicitWorkdir ?? false); + const projectRef = + options.realLayers === true + ? projectRefLayer.pipe( + Layer.provide( + Layer.succeed(CommandPlatformApiFactory, { + make: CommandPlatformApi.pipe(Effect.provide(http.layer)), + }), + ), + Layer.provide(cliSettings), + Layer.provide(tty), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ) + : testProjectRefLayer(options.linked !== false); + const linkedProjectCache = + options.realLayers === true + ? realLinkedProjectCache({ httpClient: http.httpClientLayer, cliSettings }) + : mockLinkedProjectCacheLayer; return { out, @@ -489,11 +528,11 @@ export function setupCompute(options: ComputeSetupOptions) { out.layer, http.layer, mockRuntimeInfo({ cwd: options.cwd ?? options.workdir }), - mockTty({ stdinIsTty: options.stdinIsTty ?? interactive, stdoutIsTty: interactive }), - testCliConfigLayer(options.workdir, options.explicitWorkdir ?? false), - testProjectRefLayer(options.linked !== false), + tty, + cliSettings, + projectRef, telemetry.layer, - mockLinkedProjectCacheLayer, + linkedProjectCache, randomLayer, Layer.succeed( OutputFlag, diff --git a/apps/cli/tests/helpers/permission-pending-commands.ts b/apps/cli/tests/helpers/permission-pending-commands.ts index e39ac531ba..90e400e839 100644 --- a/apps/cli/tests/helpers/permission-pending-commands.ts +++ b/apps/cli/tests/helpers/permission-pending-commands.ts @@ -5,13 +5,6 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "completion powershell", "completion zsh", - "compute delete", - "compute list", - "compute logs", - "compute new", - "compute push", - "compute status", - "db branch create", "db branch delete", "db branch list", @@ -21,13 +14,6 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "feedback add", "feedback delete", - "functions delete", - "functions deploy", - "functions download", - "functions list", - "functions new", - "functions serve", - "gen keys", "init", diff --git a/apps/cli/tests/helpers/permission-registry.unit.test.ts b/apps/cli/tests/helpers/permission-registry.unit.test.ts index e5b7e5c967..3854f08176 100644 --- a/apps/cli/tests/helpers/permission-registry.unit.test.ts +++ b/apps/cli/tests/helpers/permission-registry.unit.test.ts @@ -156,8 +156,8 @@ describe("applicableOperations", () => { describe("declaredPermissions", () => { it("tells a path missing from the tree apart from a command with no declaration", () => { expect(declaredPermissions("not-a-real-command")).toEqual({ _tag: "NotFound" }); - expect(declaredPermissions("compute list")).toEqual({ _tag: "NotFound" }); - expect(declaredPermissions("compute list", { computeEnabled: true })).toEqual({ + expect(declaredPermissions("stack list")).toEqual({ _tag: "NotFound" }); + expect(declaredPermissions("stack list", { stackBackend: "stack" })).toEqual({ _tag: "Undeclared", }); }); From a33850e73cea9719094d5f1e0c3c411e14d49ca4 Mon Sep 17 00:00:00 2001 From: Colum Ferry Date: Thu, 15 Oct 2026 10:52:25 +0000 Subject: [PATCH 15/26] fix(cli): check the Docker client version before passing volume prune --all (#7177) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `supabase stop --no-backup` no longer exits 1 when a Docker CLI older than 23.0 talks to a newer daemon. `docker volume prune --all` is a client-side flag. Docker CLI 20.10 doesn't have it, and newer CLIs reject it when the negotiated API version is below 1.42. The gate only read the daemon's API version, so a 20.10 client against a 25.0 daemon was handed `--all`: the containers were already gone, the volume prune failed, and `stop` reported `StopVolumePruneError`. The gate now reads `{{.Client.APIVersion}} {{.Server.APIVersion}}` and passes `--all` only when both are 1.42 or later. A request negotiated below 1.42 already prunes named volumes without the flag, so nothing is left behind. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../cli/src/command-internal/container-cli.ts | 32 +++++--- .../container-cli.unit.test.ts | 82 ++++++++++--------- .../src/command-internal/docker-remove-all.ts | 2 +- apps/cli/src/commands/stop/SIDE_EFFECTS.md | 11 +-- .../commands/stop/stop.integration.test.ts | 47 +++++++++-- 5 files changed, 109 insertions(+), 65 deletions(-) diff --git a/apps/cli/src/command-internal/container-cli.ts b/apps/cli/src/command-internal/container-cli.ts index c8ccc780a1..05687938f6 100644 --- a/apps/cli/src/command-internal/container-cli.ts +++ b/apps/cli/src/command-internal/container-cli.ts @@ -296,29 +296,35 @@ function isDockerApiVersionAtLeast(version: string, minVersion: string): boolean } /** - * Checks whether the daemon's negotiated API version supports `docker volume prune --all` - * (added in 1.42). Older daemons reject the flag outright instead of ignoring it, so this must be - * checked via `docker version` before use; there's no persistent Engine API client to ask - * directly. Does not fall back to Podman, whose `volume prune` has no `--all` flag to gate. - * Resolves to `false` on any failure to spawn `docker` or read its version — the same effect as - * omitting `--all` on a pre-1.42 daemon, which already prunes every unused volume without it. + * Checks whether both the Docker client and daemon API versions are >= 1.42, the minimum for + * `docker volume prune --all`. The flag is client-side and validated against the negotiated + * version, so an old client against a new daemon still rejects it. Resolves to `false` on any + * failure to spawn `docker` or read its versions; omitting `--all` is the safe fallback. Does not + * fall back to Podman, whose `volume prune` has no `--all` flag to gate. */ export const dockerSupportsVolumePruneAllFlag = (spawner: Spawner) => Effect.scoped( Effect.gen(function* () { const child = yield* spawner.spawn( - ChildProcess.make("docker", ["version", "--format", "{{.Server.APIVersion}}"], { - stdin: "ignore", - stdout: "pipe", - stderr: "ignore", - }), + ChildProcess.make( + "docker", + ["version", "--format", "{{.Client.APIVersion}} {{.Server.APIVersion}}"], + { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + }, + ), ); const [exitCode, stdout] = yield* Effect.all( [child.exitCode.pipe(Effect.map(Number)), collectText(child.stdout)], { concurrency: "unbounded" }, ); if (exitCode !== 0) return false; - const version = stdout.trim(); - return version.length > 0 && isDockerApiVersionAtLeast(version, "1.42"); + const versions = stdout.trim().split(/\s+/); + return ( + versions.length === 2 && + versions.every((version) => isDockerApiVersionAtLeast(version, "1.42")) + ); }), ).pipe(Effect.orElseSucceed(() => false)); diff --git a/apps/cli/src/command-internal/container-cli.unit.test.ts b/apps/cli/src/command-internal/container-cli.unit.test.ts index 64ed52331d..d65317e74c 100644 --- a/apps/cli/src/command-internal/container-cli.unit.test.ts +++ b/apps/cli/src/command-internal/container-cli.unit.test.ts @@ -17,6 +17,7 @@ function mockSpawner( readonly bothMissing?: boolean; readonly exitCode?: number; readonly stdout?: string; + readonly apiVersions?: { readonly client: string; readonly server: string }; } = {}, ) { const spawned: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; @@ -36,14 +37,21 @@ function mockSpawner( }); } + const stdout = + opts.apiVersions !== undefined && args[0] === "version" + ? (args[args.indexOf("--format") + 1] ?? "") + .replaceAll("{{.Client.APIVersion}}", opts.apiVersions.client) + .replaceAll("{{.Server.APIVersion}}", opts.apiVersions.server) + : opts.stdout; + const exitDeferred = yield* Deferred.make(); yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(opts.exitCode ?? 0)); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), stdout: - opts.stdout !== undefined - ? Stream.fromIterable([new TextEncoder().encode(opts.stdout)]) + stdout !== undefined + ? Stream.fromIterable([new TextEncoder().encode(stdout)]) : Stream.empty, stderr: Stream.empty, all: Stream.empty, @@ -222,29 +230,26 @@ describe("container CLI process spans", () => { }); describe("dockerSupportsVolumePruneAllFlag", () => { - it.live("returns true when the daemon reports an API version at or above 1.42", () => { - const mock = mockSpawner({ stdout: "1.42" }); - return dockerSupportsVolumePruneAllFlag(mock.spawner).pipe( - Effect.map((supportsAll) => { - expect(supportsAll).toBe(true); - expect(mock.spawned).toEqual([ - { command: "docker", args: ["version", "--format", "{{.Server.APIVersion}}"] }, - ]); - }), - ); - }); - - it.live("returns true for a version comfortably above 1.42", () => { - const mock = mockSpawner({ stdout: "1.51" }); - return dockerSupportsVolumePruneAllFlag(mock.spawner).pipe( - Effect.map((supportsAll) => { - expect(supportsAll).toBe(true); - }), - ); - }); + it.live( + "returns true when the client and daemon both report an API version at or above 1.42", + () => { + const mock = mockSpawner({ apiVersions: { client: "1.42", server: "1.51" } }); + return dockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(true); + expect(mock.spawned).toEqual([ + { + command: "docker", + args: ["version", "--format", "{{.Client.APIVersion}} {{.Server.APIVersion}}"], + }, + ]); + }), + ); + }, + ); - it.live("returns false when the daemon reports an API version below 1.42", () => { - const mock = mockSpawner({ stdout: "1.41" }); + it.live("returns false for an old client against a new daemon", () => { + const mock = mockSpawner({ apiVersions: { client: "1.41", server: "1.44" } }); return dockerSupportsVolumePruneAllFlag(mock.spawner).pipe( Effect.map((supportsAll) => { expect(supportsAll).toBe(false); @@ -252,10 +257,20 @@ describe("dockerSupportsVolumePruneAllFlag", () => { ); }); - it.live("compares version components numerically, not lexicographically", () => { - // A naive string compare would misorder "1.9" as greater than "1.42" — this - // guards the numeric, component-by-component comparison instead. - const mock = mockSpawner({ stdout: "1.9" }); + it.live.each([ + { + name: "a new client against an old daemon", + options: { apiVersions: { client: "1.44", server: "1.41" } }, + }, + { + name: "versions that only compare above 1.42 lexicographically", + options: { apiVersions: { client: "1.9", server: "1.9" } }, + }, + { name: "a single version instead of a client and daemon pair", options: { stdout: "1.51" } }, + { name: "more than two versions", options: { stdout: "1.51 1.51 1.51" } }, + { name: "empty output", options: { stdout: "" } }, + ])("returns false for $name", ({ options }) => { + const mock = mockSpawner(options); return dockerSupportsVolumePruneAllFlag(mock.spawner).pipe( Effect.map((supportsAll) => { expect(supportsAll).toBe(false); @@ -264,16 +279,7 @@ describe("dockerSupportsVolumePruneAllFlag", () => { }); it.live("returns false when the version command exits non-zero", () => { - const mock = mockSpawner({ exitCode: 1, stdout: "1.51" }); - return dockerSupportsVolumePruneAllFlag(mock.spawner).pipe( - Effect.map((supportsAll) => { - expect(supportsAll).toBe(false); - }), - ); - }); - - it.live("returns false when the reported version is empty", () => { - const mock = mockSpawner({ stdout: "" }); + const mock = mockSpawner({ exitCode: 1, apiVersions: { client: "1.51", server: "1.51" } }); return dockerSupportsVolumePruneAllFlag(mock.spawner).pipe( Effect.map((supportsAll) => { expect(supportsAll).toBe(false); diff --git a/apps/cli/src/command-internal/docker-remove-all.ts b/apps/cli/src/command-internal/docker-remove-all.ts index 40cc21333e..9612139963 100644 --- a/apps/cli/src/command-internal/docker-remove-all.ts +++ b/apps/cli/src/command-internal/docker-remove-all.ts @@ -213,7 +213,7 @@ export const dockerRemoveAll = ( if (deleteVolumes) { // `--all` requires Docker API >= 1.42 and is validated by the Docker CLI itself, so passing - // it unconditionally would hard-fail on an older daemon; ask `docker version` via + // it unconditionally would hard-fail on an older client or daemon; ask `docker version` via // {@link dockerSupportsVolumePruneAllFlag} instead of negotiating the API version directly. // Podman has no `--all` flag on `volume prune` at all, but already prunes every unused // volume by default, so omitting `--all` there is a lossless fallback. diff --git a/apps/cli/src/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/stop/SIDE_EFFECTS.md index 78a37a83ae..97585d1337 100644 --- a/apps/cli/src/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/stop/SIDE_EFFECTS.md @@ -160,12 +160,13 @@ Same payload as `json`, delivered as a `result` NDJSON event. matches this exactly: `deleteVolumes = flags.noBackup`. `--backup=false` alone does **not** delete volumes; only `--no-backup` does. -- Volume prune gates `--all` on the Docker daemon's API version (`container-cli.ts`'s +- Volume prune gates `--all` on the Docker client and daemon API versions (`container-cli.ts`'s `dockerSupportsVolumePruneAllFlag`, checked via `docker version --format -'{{.Server.APIVersion}}'`) — Docker requires server API version >= 1.42. This isn't - cosmetic: Docker CLI's own `--all` flag on `volume prune` is annotated `version: "1.42"` - and enforced before pruning runs, so sending it unconditionally on a pre-1.42 daemon - hard-fails the whole call instead of just pruning a narrower set. On the Podman fallback, +'{{.Client.APIVersion}} {{.Server.APIVersion}}'`) — Docker requires both to be >= 1.42. This + isn't cosmetic: a pre-23.0 Docker CLI doesn't know `--all` on `volume prune`, and a newer + CLI annotates it `version: "1.42"` and rejects it when the daemon is below 1.42, before + pruning runs, so sending it unconditionally hard-fails the whole call instead of just + pruning a narrower set. On the Podman fallback, `--all` is omitted unconditionally instead: no released Podman `volume prune` (checked v4.3 through the current v5.7) accepts that flag, and Podman already prunes every unused volume by default, so dropping it there is lossless. Podman itself is a TS-only fallback diff --git a/apps/cli/src/commands/stop/stop.integration.test.ts b/apps/cli/src/commands/stop/stop.integration.test.ts index d96b48ad17..3a25eebdf3 100644 --- a/apps/cli/src/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/stop/stop.integration.test.ts @@ -159,24 +159,35 @@ function mockRoutedContainerCliSpawner( } /** - * Default happy-path router: `ps` lists one container, `docker version` reports an API version - * above the `volume prune --all` gate (see `dockerSupportsVolumePruneAllFlag`), everything else - * succeeds empty. + * Default happy-path router: `ps` lists one container, `docker version` renders the requested + * `--format` with client and daemon API versions above the `volume prune --all` gate (see + * `dockerSupportsVolumePruneAllFlag`), everything else succeeds empty. */ function defaultRoute( opts: { readonly containerIds?: ReadonlyArray; readonly volumeNames?: ReadonlyArray; - readonly dockerApiVersion?: string; + readonly dockerClientApiVersion?: string; + readonly dockerServerApiVersion?: string; } = {}, ) { const containerIds = opts.containerIds ?? ["c1"]; const volumeNames = opts.volumeNames ?? []; - const dockerApiVersion = opts.dockerApiVersion ?? "1.45"; + const dockerClientApiVersion = opts.dockerClientApiVersion ?? "1.45"; + const dockerServerApiVersion = opts.dockerServerApiVersion ?? "1.45"; return (args: ReadonlyArray): RouteResult => { if (args[0] === "ps") return { stdout: containerIds.map((id) => `${id}\t${id}`) }; if (args[0] === "volume" && args[1] === "ls") return { stdout: volumeNames }; - if (args[0] === "version") return { stdout: [dockerApiVersion] }; + if (args[0] === "version") { + const format = args[args.indexOf("--format") + 1] ?? ""; + return { + stdout: [ + format + .replaceAll("{{.Client.APIVersion}}", dockerClientApiVersion) + .replaceAll("{{.Server.APIVersion}}", dockerServerApiVersion), + ], + }; + } return { exitCode: 0 }; }; } @@ -717,7 +728,7 @@ describe("stop integration", () => { // nothing) on an older daemon. const { layer, child } = yield* setup({ configuredProjectId: "demo", - route: defaultRoute({ dockerApiVersion: "1.41" }), + route: defaultRoute({ dockerServerApiVersion: "1.41" }), }); yield* stop(flags({ noBackup: true })).pipe(Effect.provide(layer)); const volumePrune = child.spawned.find( @@ -733,11 +744,31 @@ describe("stop integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("omits --all from docker's volume prune when an old client talks to a new daemon", () => + Effect.gen(function* () { + const { layer, child } = yield* setup({ + configuredProjectId: "demo", + route: defaultRoute({ dockerClientApiVersion: "1.41", dockerServerApiVersion: "1.44" }), + }); + yield* stop(flags({ noBackup: true })).pipe(Effect.provide(layer)); + const volumePrune = child.spawned.find( + (s) => s.command === "docker" && s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune?.args).toEqual([ + "volume", + "prune", + "--force", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("includes --all in docker's volume prune when the API is exactly 1.42", () => Effect.gen(function* () { const { layer, child } = yield* setup({ configuredProjectId: "demo", - route: defaultRoute({ dockerApiVersion: "1.42" }), + route: defaultRoute({ dockerClientApiVersion: "1.42", dockerServerApiVersion: "1.42" }), }); yield* stop(flags({ noBackup: true })).pipe(Effect.provide(layer)); const volumePrune = child.spawned.find( From e2fff348afc7ae1084b33d3dbd2286f7eddf654b Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 10:56:42 +0000 Subject: [PATCH 16/26] chore(stack): bump realtime to v2.144.0-r0 (#7181) This bumps realtime from v2.143.4-r0 to v2.144.0-r0. https://github.com/supabase/slim-services/releases/tag/realtime-v2.144.0-r0 Planned after supabase/slim-services published v2.144.0-r0. This branch is rewritten from develop whenever a newer relevant release arrives. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- apps/cli/src/shared/services/Dockerfile | 2 +- packages/stack/src/Artifacts.ts | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 477bd2572c..a0250c30b2 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -33,7 +33,7 @@ FROM supabase/supavisor:2.9.13 AS supavisor FROM supabase/gotrue:v2.197.0 AS gotrue -FROM supabase/realtime:v2.143.4 AS realtime +FROM supabase/realtime:v2.144.0 AS realtime FROM supabase/storage-api:v1.83.1 AS storage diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index 477bd2572c..a0250c30b2 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -33,7 +33,7 @@ FROM supabase/supavisor:2.9.13 AS supavisor FROM supabase/gotrue:v2.197.0 AS gotrue -FROM supabase/realtime:v2.143.4 AS realtime +FROM supabase/realtime:v2.144.0 AS realtime FROM supabase/storage-api:v1.83.1 AS storage diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 30cf5b7684..f289949f2e 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -241,23 +241,23 @@ const definitions: Readonly> = { realtime: definition( "realtime", { - upstreamVersion: "v2.143.4", + upstreamVersion: "v2.144.0", revision: 0, image: - "ghcr.io/supabase/cli/realtime:v2.143.4-r0@sha256:902db0b55daecaf2f0c9fa50f4c73606be267d4f1eb004185be1bc00549ca2e6", - upstreamImage: "supabase/realtime:v2.143.4", + "ghcr.io/supabase/cli/realtime:v2.144.0-r0@sha256:220bccaef542dd8200071a14cd009f749ba422ccdce45cb6cdb93f57f260675e", + upstreamImage: "supabase/realtime:v2.144.0", natives: { "darwin-arm64": { - archive: "930c58118a6146bf6836935ff54cb9ca49f6cc0dfa2949e9a16c62d572a26a74", - manifest: "4183263200339bd30e241df1fa31235481d4a0d85560d541360a900511c46597", + archive: "8da707f0b27f22bfd25244f47d4a4471e2fbec3b0d596e1d334a645f254d0dec", + manifest: "8cc57d995fdb59f72377d889a87318937a234fb185a5c614a2d109994ca6d2f1", }, "linux-amd64": { - archive: "dfb1308c03a789228afdf2fd92b533efdccb08eec4c2e5801e70333ab7d78b75", - manifest: "caa2c5defee9dea6464fd3b818559b77a64dfec296418f33b37dc46c56682f58", + archive: "cb80975767fdb1a383cc518a29d63a3af5ee09f835b4ee7341c18f0be4dd6678", + manifest: "cc2c2ff383e22e7fe384ec4092766ea2caa380644bc7d13960b539d4b2cf7abd", }, "linux-arm64": { - archive: "0518d38ba40e844f3a1ae07ee1adf20b06f7f5cdcfe085825a4be6ec50eb9c17", - manifest: "dcf4883e6b868240b2129f2ee7b0e8525518769a671881d3a097a6803d94e2b5", + archive: "06eee5ce268be7877a9d27dd781117d60fd1b0836d915f19033ee256e01ed058", + manifest: "73483a0636dff15fd7a4f83f8e751d6a86b25f8681d7efd3deb08396f0229eb6", }, }, }, From acee469ddf5dc0aaece26811089ac0294291b94a Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Thu, 15 Oct 2026 11:06:16 +0000 Subject: [PATCH 17/26] chore(cli): map login, whoami and local command permissions (#7178) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps the permissions of `login`, `whoami` and the local-only commands (`logout`, `feedback add|delete`, `issue bug|docs|feature`, `completion bash|fish|powershell|zsh`, `telemetry enable|disable|status`), so scoped tokens can tell which of them need Management API access. - `login` composes the existing best-effort `loginProfile` block: the profile lookup only stitches the telemetry identity, and login succeeds when it fails. - `whoami` declares `v1-get-profile` as required, since the profile is its whole output. - The other 13 leaves can't reach the Management API, so they declare no operations and classify their flags as having no API effect. They get no drift checks, which would be vacuous. - `v1-get-profile` has no FGA permission in the spec, so it becomes the first `OPERATIONS_WITHOUT_FGA` entry (a shared test change). - Tests: `login` now runs the real login API layer against a recording HTTP mock (`--token` path, a 403 on the profile lookup, and the browser flow); `whoami` gains drift checks on its happy and 401 paths. Notes: - `login`'s device-flow poll (`/platform/cli/login/{sessionId}`) isn't in the spec, so it can't be declared. - `bootstrap` can reach the same profile call when no token resolves; that path isn't changed here. - `whoami -o …` fails before its required call, so the declaration over-declares that error run. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 --- .../command-permissions.unit.test.ts | 7 +- .../commands/completion/bash/bash.command.ts | 2 + .../commands/completion/fish/fish.command.ts | 2 + .../powershell/powershell.command.ts | 2 + .../commands/completion/zsh/zsh.command.ts | 2 + .../src/commands/feedback/add/add.command.ts | 2 + .../feedback/delete/delete.command.ts | 2 + apps/cli/src/commands/issue/issue.command.ts | 38 ++++++++ apps/cli/src/commands/login/login.command.ts | 6 ++ .../commands/login/login.integration.test.ts | 90 ++++++++++++++++++- .../cli/src/commands/logout/logout.command.ts | 2 + .../telemetry/disable/disable.command.ts | 2 + .../telemetry/enable/enable.command.ts | 2 + .../telemetry/status/status.command.ts | 2 + .../cli/src/commands/whoami/whoami.command.ts | 2 + .../whoami/whoami.integration.test.ts | 5 +- .../helpers/permission-pending-commands.ts | 22 ----- 17 files changed, 164 insertions(+), 26 deletions(-) diff --git a/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts b/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts index 14b5a8934f..c06176921d 100644 --- a/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts +++ b/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts @@ -32,7 +32,12 @@ const GO_DELEGATED_PATHS: ReadonlySet = new Set([ * keyed by `operationId` with the reason. A missing extension otherwise fails the test, since it * doesn't say whether a scoped token can call the operation. */ -const OPERATIONS_WITHOUT_FGA: ReadonlyMap = new Map([]); +const OPERATIONS_WITHOUT_FGA: ReadonlyMap = new Map([ + [ + "v1-get-profile", + "returns the caller's own profile; the spec declares no FGA permission for it", + ], +]); const VARIANTS: ReadonlyArray<{ readonly name: string; readonly variant: PermissionVariant }> = [ { name: "default backend", variant: undefined }, diff --git a/apps/cli/src/commands/completion/bash/bash.command.ts b/apps/cli/src/commands/completion/bash/bash.command.ts index b1e3db687e..f045e1d091 100644 --- a/apps/cli/src/commands/completion/bash/bash.command.ts +++ b/apps/cli/src/commands/completion/bash/bash.command.ts @@ -5,6 +5,7 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { CompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { completionBash } from "./bash.handler.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = { noDescriptions: CompletionNoDescriptionsFlagDef, @@ -26,6 +27,7 @@ export const completionBashCommand = Command.make("bash", config).pipe( "You will need to start a new shell for this setup to take effect.", ), Command.withShortDescription("Generate the autocompletion script for bash"), + withPermissions({ operations: [], noApiEffectFlags: ["no-descriptions"] }), Command.withHandler((flags) => completionBash(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/completion/fish/fish.command.ts b/apps/cli/src/commands/completion/fish/fish.command.ts index 594a89899f..1913bc05d6 100644 --- a/apps/cli/src/commands/completion/fish/fish.command.ts +++ b/apps/cli/src/commands/completion/fish/fish.command.ts @@ -5,6 +5,7 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { CompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { completionFish } from "./fish.handler.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = { noDescriptions: CompletionNoDescriptionsFlagDef, @@ -21,6 +22,7 @@ export const completionFishCommand = Command.make("fish", config).pipe( "You will need to start a new shell for this setup to take effect.", ), Command.withShortDescription("Generate the autocompletion script for fish"), + withPermissions({ operations: [], noApiEffectFlags: ["no-descriptions"] }), Command.withHandler((flags) => completionFish(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/completion/powershell/powershell.command.ts b/apps/cli/src/commands/completion/powershell/powershell.command.ts index 7afb1a75e5..b8197ec403 100644 --- a/apps/cli/src/commands/completion/powershell/powershell.command.ts +++ b/apps/cli/src/commands/completion/powershell/powershell.command.ts @@ -5,6 +5,7 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { CompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { completionPowershell } from "./powershell.handler.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = { noDescriptions: CompletionNoDescriptionsFlagDef, @@ -20,6 +21,7 @@ export const completionPowershellCommand = Command.make("powershell", config).pi "to your powershell profile.", ), Command.withShortDescription("Generate the autocompletion script for powershell"), + withPermissions({ operations: [], noApiEffectFlags: ["no-descriptions"] }), Command.withHandler((flags) => completionPowershell(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/completion/zsh/zsh.command.ts b/apps/cli/src/commands/completion/zsh/zsh.command.ts index b80c3e3871..08d969c0ad 100644 --- a/apps/cli/src/commands/completion/zsh/zsh.command.ts +++ b/apps/cli/src/commands/completion/zsh/zsh.command.ts @@ -5,6 +5,7 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { CompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { completionZsh } from "./zsh.handler.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = { noDescriptions: CompletionNoDescriptionsFlagDef, @@ -27,6 +28,7 @@ export const completionZshCommand = Command.make("zsh", config).pipe( "You will need to start a new shell for this setup to take effect.", ), Command.withShortDescription("Generate the autocompletion script for zsh"), + withPermissions({ operations: [], noApiEffectFlags: ["no-descriptions"] }), Command.withHandler((flags) => completionZsh(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/feedback/add/add.command.ts b/apps/cli/src/commands/feedback/add/add.command.ts index 3aa6b98f18..283e44b881 100644 --- a/apps/cli/src/commands/feedback/add/add.command.ts +++ b/apps/cli/src/commands/feedback/add/add.command.ts @@ -9,6 +9,7 @@ import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts import { commandFeedbackClientLayer, feedbackCliConfigLayer } from "../feedback.layers.ts"; import { FEEDBACK_OUTPUT_FORMATS } from "../feedback-output.ts"; import { feedbackAdd } from "./add.handler.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = { message: Argument.String("message").pipe( @@ -57,6 +58,7 @@ export const feedbackAddCommand = Command.make("add", config).pipe( description: "Attribute the feedback to a project from an unlinked directory", }, ]), + withPermissions({ operations: [], noApiEffectFlags: ["project-ref"] }), Command.withHandler(feedbackAddHandler), Command.provide(commandRuntimeLayer(["feedback", "add"])), Command.provide(telemetryStateLayer), diff --git a/apps/cli/src/commands/feedback/delete/delete.command.ts b/apps/cli/src/commands/feedback/delete/delete.command.ts index bdf3f48e17..0e76cd241d 100644 --- a/apps/cli/src/commands/feedback/delete/delete.command.ts +++ b/apps/cli/src/commands/feedback/delete/delete.command.ts @@ -8,6 +8,7 @@ import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts import { commandFeedbackClientLayer, feedbackCliConfigLayer } from "../feedback.layers.ts"; import { FEEDBACK_OUTPUT_FORMATS } from "../feedback-output.ts"; import { feedbackDelete } from "./delete.handler.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = { token: Argument.String("token").pipe( @@ -44,6 +45,7 @@ export const feedbackDeleteCommand = Command.make("delete", config).pipe( description: "Delete feedback using the token printed when it was submitted", }, ]), + withPermissions({ operations: [], noApiEffectFlags: ["project-ref"] }), Command.withHandler(feedbackDeleteHandler), Command.provide(commandRuntimeLayer(["feedback", "delete"])), Command.provide(telemetryStateLayer), diff --git a/apps/cli/src/commands/issue/issue.command.ts b/apps/cli/src/commands/issue/issue.command.ts index 9be04f119a..becd6b5113 100644 --- a/apps/cli/src/commands/issue/issue.command.ts +++ b/apps/cli/src/commands/issue/issue.command.ts @@ -5,6 +5,7 @@ import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer. import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { issueBug, issueDocs, issueFeature } from "./issue.handler.ts"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; const issueNoBrowserFlag = Flag.Boolean("no-browser").pipe( Flag.withDescription("Print the issue form URL without opening a browser."), @@ -77,6 +78,20 @@ const issueBugCommand = Command.make("bug", issueBugConfig).pipe( description: "Print a prefilled issue URL with a crash report ID", }, ]), + withPermissions({ + operations: [], + noApiEffectFlags: [ + "area", + "command", + "actual-output", + "expected-behavior", + "reproduce", + "crash-report-id", + "docker-services", + "additional-context", + "no-browser", + ], + }), Command.withHandler((flags) => issueBug(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), @@ -94,6 +109,18 @@ const issueFeatureCommand = Command.make("feature", issueFeatureConfig).pipe( description: "Open a prefilled feature request", }, ]), + withPermissions({ + operations: [], + noApiEffectFlags: [ + "existing-issues", + "area", + "problem", + "proposed-solution", + "alternatives", + "additional-context", + "no-browser", + ], + }), Command.withHandler((flags) => issueFeature(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), @@ -111,6 +138,17 @@ const issueDocsCommand = Command.make("docs", issueDocsConfig).pipe( description: "Open a prefilled documentation issue", }, ]), + withPermissions({ + operations: [], + noApiEffectFlags: [ + "link", + "issue-type", + "problem", + "improvement", + "additional-context", + "no-browser", + ], + }), Command.withHandler((flags) => issueDocs(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/login/login.command.ts b/apps/cli/src/commands/login/login.command.ts index 40d1403f8b..177d9e7206 100644 --- a/apps/cli/src/commands/login/login.command.ts +++ b/apps/cli/src/commands/login/login.command.ts @@ -5,6 +5,9 @@ import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { loginRuntimeLayer } from "./login.layers.ts"; import { login } from "./login.handler.ts"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../command-internal/command-permissions/model.ts"; +import { loginProfile } from "../../command-internal/login-api.layer.ts"; const config = { token: Flag.String("token").pipe( @@ -26,6 +29,9 @@ export type LoginFlags = CliCommand.Command.Config.Infer; export const loginCommand = Command.make("login", config).pipe( Command.withDescription("Authenticate using an access token."), Command.withShortDescription("Authenticate using an access token"), + withPermissions( + compose({ operations: [], noApiEffectFlags: ["token", "name", "no-browser"] }, loginProfile), + ), Command.withHandler((flags) => login(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/login/login.integration.test.ts b/apps/cli/src/commands/login/login.integration.test.ts index b9edf9f1bd..135a60f33a 100644 --- a/apps/cli/src/commands/login/login.integration.test.ts +++ b/apps/cli/src/commands/login/login.integration.test.ts @@ -14,10 +14,14 @@ import { } from "../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { ProfileFlag } from "../../command-internal/global-flags.ts"; +import { loginApiLayer } from "../../command-internal/login-api.layer.ts"; import { loginCryptoLayer } from "../../command-internal/login-crypto.layer.ts"; +import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; import { VALID_TOKEN, buildTestRuntime, + jsonResponse, + mockCommandPlatformApi, mockCommandSettings, mockCommandCredentialsTracked, mockLoginApi, @@ -47,6 +51,8 @@ interface SetupOpts { readonly pipedStdin?: string; readonly gotrueId?: string; readonly profileFails?: boolean; + /** Runs the real login API layer against this recorded HTTP mock instead of the service-level mock. */ + readonly httpApi?: Parameters[0]; readonly failTimes?: number; readonly decryptFails?: boolean; readonly keygenFails?: boolean; @@ -85,6 +91,7 @@ function setupLogin(opts: SetupOpts = {}) { profileFails: opts.profileFails, }); const analytics = mockAnalytics(); + const httpApi = opts.httpApi === undefined ? undefined : mockCommandPlatformApi(opts.httpApi); const cliSettings = mockCommandSettings({ workdir: tempRoot.current, profileEnvValue: opts.profileEnvValue, @@ -111,13 +118,15 @@ function setupLogin(opts: SetupOpts = {}) { }), credentials.layer, crypto.layer, - loginApi.layer, + httpApi === undefined + ? loginApi.layer + : loginApiLayer.pipe(Layer.provide(httpApi.httpClientLayer), Layer.provide(cliSettings)), mockStdin(isTTY, opts.pipedStdin), mockBrowser(), Layer.succeed(ProfileFlag, opts.profileFlag ?? "supabase"), ...(opts.argv !== undefined ? [Layer.succeed(CliArgs, { args: opts.argv })] : []), ); - return { layer, out, credentials, crypto, loginApi, telemetry, analytics }; + return { layer, out, credentials, crypto, loginApi, telemetry, analytics, httpApi }; } describe("login integration", () => { @@ -279,6 +288,83 @@ describe("login integration", () => { }, ); + describe("profile lookup over the real login API", () => { + const PROFILE = { gotrue_id: "gotrue-abc", primary_email: "a@b.co", username: "ab" }; + const SESSION = { + access_token: "656e6372797074656420746f6b656e", + public_key: "04abcd", + nonce: "0102030405060708090a0b0c", + }; + + it.effect("looks up the profile once with the new token after a --token login", () => { + const { layer, out, credentials, telemetry, httpApi } = setupLogin({ + httpApi: { response: { status: 200, body: PROFILE } }, + }); + return Effect.gen(function* () { + yield* login(flags({ token: Option.some(VALID_TOKEN) })); + const requests = httpApi?.requests ?? []; + + expect(credentials.savedToken).toBe(VALID_TOKEN); + expect(out.stdoutText).toContain("You are now logged in. Happy coding!"); + expect(requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/profile", + ]); + expect(requests[0]?.headers["authorization"]).toBe(`Bearer ${VALID_TOKEN}`); + expect(telemetry.stitchedDistinctId).toBe("gotrue-abc"); + assertPermissionDrift({ command: "login", activeFlags: { token: true }, requests }); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "still logs in and clears the distinct id when the profile lookup is forbidden", + () => { + const { layer, out, credentials, telemetry, analytics, httpApi } = setupLogin({ + httpApi: { response: { status: 403, body: { message: "Forbidden" } } }, + }); + return Effect.gen(function* () { + yield* login(flags({ token: Option.some(VALID_TOKEN) })); + const requests = httpApi?.requests ?? []; + + expect(requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/profile", + ]); + expect(credentials.savedToken).toBe(VALID_TOKEN); + expect(out.stdoutText).toContain("You are now logged in. Happy coding!"); + expect(telemetry.clearedDistinctId).toBe(true); + expect(telemetry.stitchedDistinctId).toBeUndefined(); + expect(analytics.captured.map((c) => c.event)).toContain(EventLoginCompleted); + assertPermissionDrift({ command: "login", activeFlags: { token: true }, requests }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("polls the login session, then looks up the profile in the browser flow", () => { + const { layer, credentials, httpApi } = setupLogin({ + isTTY: true, + httpApi: { + handler: (request) => + Effect.succeed( + request.url.includes("/platform/cli/login/") + ? jsonResponse(request, 200, SESSION) + : jsonResponse(request, 200, PROFILE), + ), + }, + }); + return Effect.gen(function* () { + yield* login(flags()); + const requests = httpApi?.requests ?? []; + + expect(credentials.savedToken).toBe(VALID_TOKEN); + expect(requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/platform/cli/login/test-session-id?device_code=123456", + "GET https://api.supabase.com/v1/profile", + ]); + expect(requests[1]?.headers["authorization"]).toBe(`Bearer ${VALID_TOKEN}`); + assertPermissionDrift({ command: "login", requests }); + }).pipe(Effect.provide(layer)); + }); + }); + it.effect("flushes telemetry state via ensuring", () => { const { layer, telemetry } = setupLogin(); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/logout/logout.command.ts b/apps/cli/src/commands/logout/logout.command.ts index 970cdfd28a..26b5086d0c 100644 --- a/apps/cli/src/commands/logout/logout.command.ts +++ b/apps/cli/src/commands/logout/logout.command.ts @@ -4,10 +4,12 @@ import { withJsonErrorHandling } from "../../shared/output/json-error-handling.t import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { logoutRuntimeLayer } from "./logout.layers.ts"; import { logout } from "./logout.handler.ts"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; export const logoutCommand = Command.make("logout").pipe( Command.withDescription("Log out and delete access tokens locally."), Command.withShortDescription("Log out and delete access tokens locally"), + withPermissions({ operations: [] }), Command.withHandler(() => logout().pipe(withCommandTelemetry(), withJsonErrorHandling)), Command.provide(logoutRuntimeLayer), ); diff --git a/apps/cli/src/commands/telemetry/disable/disable.command.ts b/apps/cli/src/commands/telemetry/disable/disable.command.ts index d8795b07f7..ace0dafaa6 100644 --- a/apps/cli/src/commands/telemetry/disable/disable.command.ts +++ b/apps/cli/src/commands/telemetry/disable/disable.command.ts @@ -4,6 +4,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { telemetryDisable } from "./disable.handler.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = {}; export type TelemetryDisableFlags = CliCommand.Command.Config.Infer; @@ -11,6 +12,7 @@ export type TelemetryDisableFlags = CliCommand.Command.Config.Infer // `cli_command_executed` fires based on the consent value read at layer-construction // time, before this handler mutates it — so `disable` still fires when telemetry was diff --git a/apps/cli/src/commands/telemetry/enable/enable.command.ts b/apps/cli/src/commands/telemetry/enable/enable.command.ts index dd1eba8da3..e868fef81e 100644 --- a/apps/cli/src/commands/telemetry/enable/enable.command.ts +++ b/apps/cli/src/commands/telemetry/enable/enable.command.ts @@ -4,6 +4,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { telemetryEnable } from "./enable.handler.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = {}; export type TelemetryEnableFlags = CliCommand.Command.Config.Infer; @@ -11,6 +12,7 @@ export type TelemetryEnableFlags = CliCommand.Command.Config.Infer // `cli_command_executed` fires based on the consent value read at layer-construction // time: silent when enabling from a disabled state, since the snapshot is `false`. diff --git a/apps/cli/src/commands/telemetry/status/status.command.ts b/apps/cli/src/commands/telemetry/status/status.command.ts index 20ca21cf00..a93ee1ab98 100644 --- a/apps/cli/src/commands/telemetry/status/status.command.ts +++ b/apps/cli/src/commands/telemetry/status/status.command.ts @@ -4,6 +4,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { telemetryStatus } from "./status.handler.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = {}; export type TelemetryStatusFlags = CliCommand.Command.Config.Infer; @@ -11,6 +12,7 @@ export type TelemetryStatusFlags = CliCommand.Command.Config.Infer telemetryStatus(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/whoami/whoami.command.ts b/apps/cli/src/commands/whoami/whoami.command.ts index 3bb8c56210..f31aab800e 100644 --- a/apps/cli/src/commands/whoami/whoami.command.ts +++ b/apps/cli/src/commands/whoami/whoami.command.ts @@ -6,6 +6,7 @@ import { managementApiRuntimeLayer } from "../../command-internal/management-api import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { whoami } from "./whoami.handler.ts"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = {}; export type WhoamiFlags = CliCommand.Command.Config.Infer; @@ -32,6 +33,7 @@ export const whoamiCommand = Command.make("whoami", config).pipe( description: "Show the current user as JSON", }, ]), + withPermissions({ operations: [{ operationId: "v1-get-profile", kind: "required" }] }), Command.withHandler(whoamiHandler), Command.provide(managementApiRuntimeLayer(["whoami"])), ); diff --git a/apps/cli/src/commands/whoami/whoami.integration.test.ts b/apps/cli/src/commands/whoami/whoami.integration.test.ts index 7dc8e74d7b..1c3b82132d 100644 --- a/apps/cli/src/commands/whoami/whoami.integration.test.ts +++ b/apps/cli/src/commands/whoami/whoami.integration.test.ts @@ -8,6 +8,7 @@ import { InvalidOutputFormatError } from "../../command-internal/go-output-flag. import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; import { ErrorActionabilityId } from "../../shared/telemetry/error-actionability.ts"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; import { buildTestRuntime, mockCommandPlatformApi, @@ -83,6 +84,7 @@ describe("whoami integration", () => { body: undefined, urlParams: "", }); + assertPermissionDrift({ command: "whoami", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -127,7 +129,7 @@ describe("whoami integration", () => { }); it.effect("points an unauthorized profile request at re-authenticating", () => { - const { layer } = setup({ status: 401, response: { message: "Unauthorized" } }); + const { layer, api } = setup({ status: 401, response: { message: "Unauthorized" } }); return Effect.gen(function* () { const error = findError(yield* whoami({}).pipe(Effect.exit)); expect(error).toBeInstanceOf(WhoamiUnexpectedStatusError); @@ -138,6 +140,7 @@ describe("whoami integration", () => { "Authentication failed: your access token is invalid or has expired. Run `supabase login` to re-authenticate.", ); } + assertPermissionDrift({ command: "whoami", requests: api.requests }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/tests/helpers/permission-pending-commands.ts b/apps/cli/tests/helpers/permission-pending-commands.ts index 90e400e839..a6ee864529 100644 --- a/apps/cli/tests/helpers/permission-pending-commands.ts +++ b/apps/cli/tests/helpers/permission-pending-commands.ts @@ -1,31 +1,15 @@ /** Leaf command paths that don't declare their permissions with `withPermissions` yet. */ export const PENDING_COMMANDS: ReadonlyArray = [ - "completion bash", - "completion fish", - "completion powershell", - "completion zsh", - "db branch create", "db branch delete", "db branch list", "db branch switch", "db remote changes", - "feedback add", - "feedback delete", - "gen keys", "init", - "issue bug", - "issue docs", - "issue feature", - - "login", - - "logout", - "pull", "stack destroy", @@ -42,10 +26,4 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "status", "stop", - - "telemetry disable", - "telemetry enable", - "telemetry status", - - "whoami", ]; From a98214004972dc5bb76a05b12a0fd2ed5e194da3 Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Thu, 15 Oct 2026 11:14:17 +0000 Subject: [PATCH 18/26] chore(cli): map pull command permissions (#7180) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Maps the top-level `pull` command to the Management API operations it calls. `pull` composes the config-pull, linked-project cache, database config and function-download blocks directly, because its sub-commands' declarations describe different flags and gating. The database and function steps are gated on `--dry-run` being false, since a dry run stops after the target and config reads. `pull` also declares its own pooler-config entry: its database steps always get the resolved ref, so the pooler fetch happens without `--project-ref`. Findings: a bootstrap run resolves the database twice, so it fetches the pooler config and mints a login role twice; step failures are isolated but `pull` still fails, so every step call is required. Untested conditions: a declined confirmation (it over-declares, like `--dry-run`), a failing step under the real layers, and a branch-name target under the real layers. `dbConfigLinked`, `dbConfigPoolerFallbackLinked` and the own pooler entry overlap, so dropping one alone fails no test. No blocks without a real-layer test, no best-effort label turned required, no `OPERATIONS_WITHOUT_FGA` entries. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 --- apps/cli/src/commands/pull/pull.command.ts | 36 ++ .../commands/pull/pull.integration.test.ts | 314 +++++++++++++++++- .../helpers/permission-pending-commands.ts | 2 - 3 files changed, 344 insertions(+), 8 deletions(-) diff --git a/apps/cli/src/commands/pull/pull.command.ts b/apps/cli/src/commands/pull/pull.command.ts index 27262d715a..d7e02b6862 100644 --- a/apps/cli/src/commands/pull/pull.command.ts +++ b/apps/cli/src/commands/pull/pull.command.ts @@ -2,7 +2,17 @@ import { Option } from "effect"; import type * as CliCommand from "effect/cli/Command"; import { Command, Flag } from "effect/cli"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withCondition } from "../../command-internal/command-permissions/model.ts"; +import { configPullRun } from "../../command-internal/config-pull-run.ts"; +import { + dbConfigLinked, + dbConfigPoolerFallbackLinked, +} from "../../command-internal/db-config.layer.ts"; +import { configTarget } from "../../command-internal/project-target.ts"; import { PROJECT_REF_PATTERN } from "../../config/project-ref.service.ts"; +import { functionsDownload } from "../../shared/functions/download.ts"; +import { linkedProjectCache } from "../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { GLOBAL_OUTPUT_FORMATS } from "../../command-internal/global-flags.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; @@ -41,6 +51,10 @@ const config = { ), } as const; +// `--dry-run` returns after the target and config reads, so the database and function steps run +// only without it. +const stepsRun = { flag: "dry-run", is: false } as const; + export type PullFlags = CliCommand.Command.Config.Infer; // Exported so integration tests can drive the exact wiring `Command.withHandler` @@ -82,6 +96,28 @@ export const pullCommand = Command.make("pull", config).pipe( description: "Preview what would be pulled without writing anything", }, ]), + withPermissions( + compose( + configTarget, + configPullRun, + linkedProjectCache, + { + operations: [ + { + operationId: "v1-get-pooler-config", + kind: "required", + when: [stepsRun], + context: + "the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url; the database steps get the resolved ref, so this needs no --project-ref", + }, + ], + noApiEffectFlags: ["remote-label", "force", "with-migration-history"], + }, + withCondition(dbConfigLinked, stepsRun), + withCondition(dbConfigPoolerFallbackLinked, stepsRun), + withCondition(functionsDownload, stepsRun), + ), + ), Command.withHandler(pullHandler), Command.provide(pullRuntimeLayer), ); diff --git a/apps/cli/src/commands/pull/pull.integration.test.ts b/apps/cli/src/commands/pull/pull.integration.test.ts index 53e458267d..1641f024af 100644 --- a/apps/cli/src/commands/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/pull/pull.integration.test.ts @@ -17,8 +17,33 @@ import { import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { TestClock } from "effect/testing"; +import { vi } from "vitest"; + +const fixtureNet = vi.hoisted(() => ({ directHostReachable: false })); + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net, fixtureNet)), +); + +const reachableDirectHost = withReachableDirectHost(fixtureNet); import { v2ProjectConfigResponse } from "../../../tests/helpers/config-fixtures.ts"; +import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + projectResponse, + provideEnv, + realDbConfigResolver, + realLinkedProjectCache, + recordingProjectHttpClient, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../tests/helpers/permission-real-layers.ts"; +import { withReachableDirectHost } from "../../../tests/helpers/refuse-invalid-db-hosts.ts"; import { mockContextualAnalytics, mockOutput, @@ -30,6 +55,7 @@ import { import { buildTestRuntime, VALID_REF, + VALID_TOKEN, jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, @@ -76,6 +102,9 @@ import { pullHandler } from "./pull.command.ts"; const tempRoot = useTempWorkdir("supabase-pull-int-"); +/** The effective values of `pull`'s three switches when none is named. */ +const STEP_SWITCHES = { "dry-run": false, force: false, "with-migration-history": false }; + const BRANCH_REF = "cccccccccccccccccccc"; const BRANCH_BY_NAME = { @@ -360,6 +389,7 @@ function makeDbConfigLayers( historyUpdateFails = false, ): { readonly layer: Layer.Layer; + readonly dbConnection: Layer.Layer; readonly historyUpserts: ReadonlyArray>; readonly connectedPorts: ReadonlyArray; } { @@ -395,6 +425,7 @@ function makeDbConfigLayers( return { layer: Layer.mergeAll(dbConnection, resolver), + dbConnection, historyUpserts: target.historyUpserts, connectedPorts, }; @@ -479,12 +510,17 @@ interface ApiOpts { /** Fails only this slug's `/body` download with a 500; every other slug still downloads * normally, proving a partial functions-step download reports earlier slugs as written. */ readonly functionBodyFailsForSlug?: string; + /** Serves `GET /v1/projects`, which the project picker reads. */ + readonly projectList?: boolean; } function makeApiMock(opts: ApiOpts) { return mockCommandPlatformApi({ handler: (request) => { const url = request.url; + if (opts.projectList === true && request.method === "GET" && url.endsWith("/v1/projects")) { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } if (url.includes("/v2/projects/") && url.endsWith("/config")) { return Effect.succeed( jsonResponse(request, 200, opts.configResponse ?? v2ProjectConfigResponse()), @@ -605,6 +641,13 @@ interface SetupOpts { readonly workdir?: string; /** Overrides the ambient `--experimental`/`SUPABASE_EXPERIMENTAL` gate — defaults to `false`. */ readonly experimental?: boolean; + /** Runs the real database config resolver and linked-project cache, so their Management API + * calls are recorded. The direct database host is unreachable unless a test opts in. */ + readonly realLayers?: boolean; + /** Overrides the linked project ref in settings — defaults to `Some(VALID_REF)`. */ + readonly projectId?: Option.Option; + /** Serves `GET /v1/projects` and answers the project picker with `VALID_REF`. */ + readonly picksProject?: boolean; } interface MachineSetupOpts extends SetupOpts { @@ -612,7 +655,11 @@ interface MachineSetupOpts extends SetupOpts { } function setup(opts: SetupOpts = {}) { - const out = mockOutput({ format: "text", promptConfirmResponses: opts.confirm }); + const out = mockOutput({ + format: "text", + promptConfirmResponses: opts.confirm, + ...(opts.picksProject === true ? { promptSelectResponses: [VALID_REF] } : {}), + }); const outputLayer = opts.confirmSideEffect === undefined ? out.layer @@ -641,7 +688,11 @@ function setupRuntime( const processControl = mockProcessControl(); const analytics = mockContextualAnalytics(); - const api = makeApiMock(opts.api ?? {}); + const api = makeApiMock({ + ...opts.api, + ...(opts.picksProject === true ? { projectList: true } : {}), + }); + const projectHttp = recordingProjectHttpClient(VALID_REF); const spawner = composeSpawner({ gitDirty: opts.gitDirty, gitDirtyMigrations: opts.gitDirtyMigrations, @@ -656,9 +707,11 @@ function setupRuntime( ); const pgDelta = makePgDeltaEngine(opts.diffOutcome ?? (() => ({ changes: false }))); + const workdir = opts.workdir ?? tempRoot.current; const cliSettings = mockCommandSettings({ - workdir: opts.workdir ?? tempRoot.current, - projectId: Option.some(VALID_REF), + workdir, + projectId: opts.projectId ?? Option.some(VALID_REF), + ...(opts.realLayers === true ? { projectHost: "invalid" } : {}), }); const layer = Layer.mergeAll( @@ -670,7 +723,13 @@ function setupRuntime( stdin: mockStdin(opts.stdinIsTty ?? false), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), telemetry: telemetry.layer, - linkedProjectCache: linkedProjectCache.layer, + linkedProjectCache: + opts.realLayers === true + ? realLinkedProjectCache({ + httpClient: projectHttp.layer, + cliSettings: mockCommandSettings({ workdir }), + }) + : linkedProjectCache.layer, processControl: { layer: processControl.layer }, analytics: { layer: analytics.layer }, goOutput: opts.goOutput ?? Option.none(), @@ -678,7 +737,16 @@ function setupRuntime( machineErrorContextLayer, commandRuntimeLayer(["pull"]).pipe(Layer.provide(BunServices.layer)), stdioLayer, - dbConfig.layer, + opts.realLayers === true + ? Layer.mergeAll( + dbConfig.dbConnection, + realDbConfigResolver({ + workdir, + dbConnection: dbConfig.dbConnection, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }), + ) + : dbConfig.layer, pgDelta.layer, Layer.succeed(EdgeRuntimeScript, { run: () => Effect.die("migra edge runtime unused — every db step forces pg-delta"), @@ -722,6 +790,7 @@ function setupRuntime( dbConfig, pgDelta, callOrder, + projectHttp, }; } @@ -842,6 +911,11 @@ describe("pull integration", () => { const configLookups = api.requests.filter((request) => request.url.endsWith("/config")); expect(configLookups).toHaveLength(1); expect(out.promptSelectCalls).toHaveLength(0); + assertPermissionDrift({ + command: "pull", + activeFlags: { ...STEP_SWITCHES, "project-ref": "staging" }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }, ); @@ -925,6 +999,11 @@ describe("pull integration", () => { expect(yield* readConfigFile).toBe(before); expect(yield* projectFileExists("supabase", "migrations")).toBe(false); expect(api.requests.some((request) => request.method !== "GET")).toBe(false); + assertPermissionDrift({ + command: "pull", + activeFlags: { ...STEP_SWITCHES, "dry-run": true }, + requests: api.requests, + }); expect(stepLine(out.stdoutText, "config")).toContain("planned"); expect(stepLine(out.stdoutText, "migration_history")).toContain("planned"); @@ -2104,3 +2183,226 @@ describe("pull stream-json output", () => { }, ); }); + +describe("pull permissions through the real layers", () => { + const asLines = (requests: ReadonlyArray) => + requests.map(({ method, path }) => `${method} ${path}`); + const poolerRequest = `GET /v1/projects/${VALID_REF}/config/database/pooler`; + const loginRoleRequest = `POST /v1/projects/${VALID_REF}/cli/login-role`; + const cacheRequest = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; + const configRequest = `GET https://api.supabase.com/v2/projects/${VALID_REF}/config`; + const asApiLines = (requests: ReadonlyArray<{ readonly method: string; readonly url: string }>) => + requests.map(({ method, url }) => `${method} ${url}`); + + const bootstrapSetup = () => + setup({ + yes: true, + realLayers: true, + api: { functionSlugs: ["hello"] }, + remoteMigrations: [ + { version: "20260101000000", name: "init", statements: ["create table foo ();"] }, + ], + diffOutcome: () => ({ + changes: true, + files: [{ name: "pull", sql: "alter table foo add column bar text;" }], + }), + }); + + // No `.temp/pooler-url` is saved between steps, so each database step resolves it again. + const FULL_RUN_SEQUENCE: ReadonlyArray = [ + poolerRequest, + loginRoleRequest, + poolerRequest, + loginRoleRequest, + ]; + + const REF_SCENARIOS: ReadonlyArray<{ + readonly name: string; + readonly projectRef: Option.Option; + readonly flag: Readonly>; + }> = [ + { name: "no --project-ref", projectRef: Option.none(), flag: {} }, + { + name: "--project-ref with the ref", + projectRef: Option.some(VALID_REF), + flag: { "project-ref": VALID_REF }, + }, + { + name: "an empty --project-ref, which resolves from settings", + projectRef: Option.some(""), + flag: { "project-ref": "" }, + }, + ]; + + for (const scenario of REF_SCENARIOS) { + it.effect( + `reads the config, fetches the history, pulls the schema and downloads functions for ${scenario.name}`, + () => { + const { layer, api, projectHttp } = bootstrapSetup(); + const stubbed: Array = []; + return Effect.gen(function* () { + yield* TestClock.setTime(AFTER_SEEDED_MIGRATIONS); + yield* stubManagementApi(stubbed, serveLinkedDatabase(VALID_REF)); + yield* writeConfig("[api]\nmax_rows = 500\n"); + yield* runPull(pullFlags({ projectRef: scenario.projectRef })); + + expect(asLines(stubbed)).toEqual(FULL_RUN_SEQUENCE); + expect(asApiLines(api.requests)).toEqual([ + configRequest, + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions/hello/body`, + ]); + expect(asApiLines(projectHttp.calls)).toEqual([cacheRequest]); + expect(yield* projectFileExists("supabase", ".temp", "linked-project.json")).toBe(true); + assertPermissionDrift({ + command: "pull", + activeFlags: { ...STEP_SWITCHES, ...scenario.flag }, + requests: [...api.requests, ...asRecordedRequests(stubbed), ...projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }, + ); + } + + it.effect("--dry-run reads the config and fills the cache, and makes no other request", () => { + const { layer, api, projectHttp } = setup({ + realLayers: true, + api: { functionSlugs: ["hello"] }, + }); + const stubbed: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(stubbed, serveLinkedDatabase(VALID_REF)); + yield* writeConfig(); + yield* runPull(pullFlags({ dryRun: true })); + + expect(asLines(stubbed)).toEqual([]); + expect(asApiLines(api.requests)).toEqual([configRequest]); + expect(asApiLines(projectHttp.calls)).toEqual([cacheRequest]); + const flags = { ...STEP_SWITCHES, "dry-run": true }; + assertPermissionDrift({ + command: "pull", + activeFlags: flags, + requests: [...api.requests, ...asRecordedRequests(stubbed), ...projectHttp.calls], + exact: true, + }); + expect(() => + assertPermissionDrift({ + command: "pull", + activeFlags: flags, + requests: [ + { + method: "POST", + url: `https://api.supabase.com/v1/projects/${VALID_REF}/cli/login-role`, + }, + ], + }), + ).toThrow(/condition doesn't hold/); + }).pipe( + Effect.scoped, + Effect.provide(layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("the project picker lists projects and the run still fills the real cache", () => { + const { layer, api, projectHttp, out } = setup({ + realLayers: true, + picksProject: true, + projectId: Option.none(), + stdinIsTty: true, + }); + const stubbed: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(stubbed, serveLinkedDatabase(VALID_REF)); + yield* writeConfig(); + yield* runPull(pullFlags({ dryRun: true })); + + expect(out.promptSelectCalls).toHaveLength(1); + expect(asApiLines(api.requests)).toEqual([ + "GET https://api.supabase.com/v1/projects", + configRequest, + ]); + expect(asApiLines(projectHttp.calls)).toEqual([cacheRequest]); + assertPermissionDrift({ + command: "pull", + activeFlags: { ...STEP_SWITCHES, "dry-run": true }, + requests: [...api.requests, ...asRecordedRequests(stubbed), ...projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("--with-migration-history on a populated directory resolves the database again", () => { + const { layer, api, projectHttp } = setup({ + yes: true, + realLayers: true, + api: { functionSlugs: ["hello"] }, + remoteMigrations: [{ version: "20260101000000", name: "init", statements: ["select 1;"] }], + }); + const stubbed: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(stubbed, serveLinkedDatabase(VALID_REF)); + yield* writeConfig(); + yield* seedLocalMigration("20260101000000", "init"); + yield* runPull(pullFlags({ withMigrationHistory: true })); + + expect(asLines(stubbed)).toEqual(FULL_RUN_SEQUENCE); + assertPermissionDrift({ + command: "pull", + activeFlags: { ...STEP_SWITCHES, "with-migration-history": true }, + requests: [...api.requests, ...asRecordedRequests(stubbed), ...projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("retries the schema diff through the pooler after an IPv6 failure", () => { + let diffs = 0; + const { layer, api, projectHttp } = setup({ + yes: true, + realLayers: true, + api: { functionSlugs: ["hello"] }, + remoteMigrations: [{ version: "20260101000000", name: "init", statements: ["select 1;"] }], + diffOutcome: () => { + diffs += 1; + return diffs === 1 + ? { changes: false, fail: "could not translate host name: network is unreachable" } + : { changes: false }; + }, + }); + const stubbed: Array = []; + return Effect.gen(function* () { + yield* reachableDirectHost; + yield* stubManagementApi(stubbed, serveLinkedDatabase(VALID_REF)); + yield* writeConfig(); + yield* seedLocalMigration("20260101000000"); + yield* runPull(pullFlags()); + + expect(diffs).toBe(2); + expect(asLines(stubbed)).toEqual([loginRoleRequest, poolerRequest, loginRoleRequest]); + assertPermissionDrift({ + command: "pull", + activeFlags: STEP_SWITCHES, + requests: [...api.requests, ...asRecordedRequests(stubbed), ...projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); +}); diff --git a/apps/cli/tests/helpers/permission-pending-commands.ts b/apps/cli/tests/helpers/permission-pending-commands.ts index a6ee864529..9e2f4cec2a 100644 --- a/apps/cli/tests/helpers/permission-pending-commands.ts +++ b/apps/cli/tests/helpers/permission-pending-commands.ts @@ -10,8 +10,6 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "init", - "pull", - "stack destroy", "stack list", "stack logs", From 3515aa6cdfaf51860fbab71e562ae9b61d32475a Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Thu, 15 Oct 2026 11:24:59 +0000 Subject: [PATCH 19/26] chore(cli): map stack, start, status, stop and init command permissions (#7179) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records which Management API operations each of these commands calls, as `withPermissions` declarations checked by the existing completeness and drift tests. - `init`, `stack destroy|list|logs|prepare|restart|start|status|stop` and legacy `start` and `stop` make no Management API calls; each declares no operations and classifies every own flag, hidden ones included. The aliased `start|status|stop` in the stack tree are covered by the same declarations. - Legacy `status` declares one best-effort `v1-list-all-branches` through the existing `branchTarget` block, with a context naming the linked-ref, cached-parent and token prerequisites. - Legacy `start` gets a drift check on the requests its bucket-seeding test records, and legacy `status` gets HTTP-level 200 and 403 branch-lookup tests checked against the declaration. - No new blocks, conditions or `OPERATIONS_WITHOUT_FGA` entries. Untested: the stack `start` drift check and the `SUPABASE_PROJECT_ID=''` case for `status`. - The `declaredPermissions` test now asserts `Declared` for `stack list` instead of `Undeclared`, because that leaf is no longer undeclared. A second test asserts that every pending command still in the tree is reported as `Undeclared`, and fails once the pending list is empty, at which point it can be deleted. - Reworded the `status` runtime-layer comment, which claimed `status` makes no Management API calls. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 --- .../stack/destroy/destroy.command.ts | 2 ++ .../experimental/stack/list/list.command.ts | 2 ++ .../experimental/stack/logs/logs.command.ts | 5 +++ .../stack/prepare/prepare.command.ts | 5 +++ .../stack/restart/restart.command.ts | 2 ++ .../experimental/stack/start/start.command.ts | 5 +++ .../stack/status/status.command.ts | 5 +++ .../experimental/stack/stop/stop.command.ts | 2 ++ apps/cli/src/commands/init/init.command.ts | 12 +++++++ apps/cli/src/commands/start/start.command.ts | 5 +++ .../commands/start/start.integration.test.ts | 19 +++++++++-- .../cli/src/commands/status/status.command.ts | 18 ++++++++-- .../status/status.integration.test.ts | 34 +++++++++++++++++++ apps/cli/src/commands/stop/stop.command.ts | 5 +++ .../helpers/permission-pending-commands.ts | 17 ---------- .../helpers/permission-registry.unit.test.ts | 18 +++++++--- 16 files changed, 129 insertions(+), 27 deletions(-) diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts index 2fe3d0619e..91a1f25277 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { machineErrorContextLayer } from "../../../../shared/output/machine-error-context.layer.ts"; import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; @@ -39,6 +40,7 @@ export const stackDestroyCommand = Command.make("destroy", config).pipe( description: "Permanently destroy multiple stacks by id or unique id prefix", }, ]), + withPermissions({ operations: [], noApiEffectFlags: ["stack", "stack-id"] }), Command.withHandler((flags) => stackDestroy(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/stack/list/list.command.ts b/apps/cli/src/commands/experimental/stack/list/list.command.ts index 826485911a..dd1dccbf67 100644 --- a/apps/cli/src/commands/experimental/stack/list/list.command.ts +++ b/apps/cli/src/commands/experimental/stack/list/list.command.ts @@ -1,4 +1,5 @@ import { Command } from "effect/cli"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stackList } from "./list.handler.ts"; @@ -6,6 +7,7 @@ import { stackList } from "./list.handler.ts"; export const stackListCommand = Command.make("list").pipe( Command.withDescription("List persisted managed local Supabase stacks."), Command.withShortDescription("List managed local stacks"), + withPermissions({ operations: [] }), Command.withHandler(() => stackList().pipe(withCommandTelemetry({ flags: {} }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts index 36a267b6db..0f10b13544 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts @@ -1,5 +1,6 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stackLogs } from "./logs.handler.ts"; @@ -67,6 +68,10 @@ export const stackLogsCommand = Command.make("logs", config).pipe( description: "Stream history and new lines as structured events", }, ]), + withPermissions({ + operations: [], + noApiEffectFlags: ["stack", "stack-id", "service", "follow", "tail", "since"], + }), Command.withHandler((flags) => stackLogs(flags).pipe( withCommandTelemetry({ flags, config, aliases: { f: "follow" } }), diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts index 8fc1c5d29b..ab0e46b31b 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts @@ -1,5 +1,6 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stackPrepare } from "./prepare.handler.ts"; @@ -37,6 +38,10 @@ export const stackPrepareCommand = Command.make("prepare", config).pipe( description: "Prepare one capability in a named stack", }, ]), + withPermissions({ + operations: [], + noApiEffectFlags: ["stack", "stack-id", "runtime", "capability"], + }), Command.withHandler((flags) => stackPrepare(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.command.ts b/apps/cli/src/commands/experimental/stack/restart/restart.command.ts index 1ca449e6de..acbacb5124 100644 --- a/apps/cli/src/commands/experimental/stack/restart/restart.command.ts +++ b/apps/cli/src/commands/experimental/stack/restart/restart.command.ts @@ -1,5 +1,6 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stackRestart } from "./restart.handler.ts"; @@ -28,6 +29,7 @@ export const stackRestartCommand = Command.make("restart", config).pipe( description: "Restart the current project stack", }, ]), + withPermissions({ operations: [], noApiEffectFlags: ["stack", "stack-id"] }), Command.withHandler((flags) => stackRestart(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/stack/start/start.command.ts b/apps/cli/src/commands/experimental/stack/start/start.command.ts index c7feb0eca0..f7a39945c4 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.command.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.command.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { machineErrorContextLayer } from "../../../../shared/output/machine-error-context.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; @@ -60,6 +61,10 @@ export const stackStartCommand = Command.make("start", config).pipe( description: "Start a named Docker stack", }, ]), + withPermissions({ + operations: [], + noApiEffectFlags: ["exclude", "stack", "stack-id", "runtime", "preparation", "eager"], + }), Command.withHandler((flags) => stackStart(flags).pipe( withCommandTelemetry({ flags, config, aliases: { x: "exclude" } }), diff --git a/apps/cli/src/commands/experimental/stack/status/status.command.ts b/apps/cli/src/commands/experimental/stack/status/status.command.ts index 734cfa0dab..2a6450d9bd 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.command.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.command.ts @@ -1,5 +1,6 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { stringSliceFlag } from "../../../../command-internal/string-slice-flag.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; @@ -40,6 +41,10 @@ export const stackStatusCommand = Command.make("status", config).pipe( description: "Export connection variables as dotenv", }, ]), + withPermissions({ + operations: [], + noApiEffectFlags: ["stack", "stack-id", "env", "override-name"], + }), Command.withHandler((flags) => stackStatus(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts index 38b665cdb0..e1c92a0ed8 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts @@ -1,5 +1,6 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stackStop } from "./stop.handler.ts"; @@ -30,6 +31,7 @@ export const stackStopCommand = Command.make("stop", config).pipe( description: "Stop the existing feature-a stack", }, ]), + withPermissions({ operations: [], noApiEffectFlags: ["all", "stack", "stack-id"] }), Command.withHandler((flags) => stackStop(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/init/init.command.ts b/apps/cli/src/commands/init/init.command.ts index 0d85adb6ec..48db9ba8fd 100644 --- a/apps/cli/src/commands/init/init.command.ts +++ b/apps/cli/src/commands/init/init.command.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { cliConfigProviderLayer } from "../../shared/config/cli-config-provider.layer.ts"; import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; @@ -44,6 +45,17 @@ export type InitFlags = CliCommand.Command.Config.Infer; export const initCommand = Command.make("init", config).pipe( Command.withDescription("Initialize a local project."), Command.withShortDescription("Initialize a local project"), + withPermissions({ + operations: [], + noApiEffectFlags: [ + "interactive", + "use-orioledb", + "force", + "with-vscode-workspace", + "with-vscode-settings", + "with-intellij-settings", + ], + }), Command.withHandler((flags) => init(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/start/start.command.ts b/apps/cli/src/commands/start/start.command.ts index 63d60648c2..cc154fc557 100644 --- a/apps/cli/src/commands/start/start.command.ts +++ b/apps/cli/src/commands/start/start.command.ts @@ -2,6 +2,7 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; import { stdinLayer } from "../../shared/runtime/stdin.layer.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; @@ -86,6 +87,10 @@ const startRuntimeLayer = Layer.mergeAll( export const startCommand = Command.make("start", config).pipe( Command.withDescription("Start containers for Supabase local development."), Command.withShortDescription("Start local Supabase stack"), + withPermissions({ + operations: [], + noApiEffectFlags: ["exclude", "ignore-health-check", "preview"], + }), Command.withHandler((flags) => start(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/start/start.integration.test.ts b/apps/cli/src/commands/start/start.integration.test.ts index 796196e783..c862f8e064 100644 --- a/apps/cli/src/commands/start/start.integration.test.ts +++ b/apps/cli/src/commands/start/start.integration.test.ts @@ -32,6 +32,7 @@ import { mockTty, } from "../../../tests/helpers/mocks.ts"; import { + mockCommandPlatformApi, mockCommandSettings, mockTelemetryStateTracked, useTempWorkdir, @@ -40,6 +41,7 @@ import { withEmptyConfigEnv, withEnvVar, } from "../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; import { unusedStackServices } from "../../../tests/helpers/unused-stack.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { classifyCliCauseActionability } from "../../shared/telemetry/error-actionability.ts"; @@ -476,6 +478,8 @@ interface SetupOpts { /** Observes files decoded from the in-memory tar stream passed to `docker cp -`. */ readonly onSecretCopy?: (containerPath: string, content: string) => void; readonly httpClientLayer?: Layer.Layer; + /** Replaces the factory that dies when a local start reaches for the Management API. */ + readonly platformApiFactoryLayer?: Layer.Layer; readonly configuredProjectId?: string; /** Raw `config.toml` contents — overrides `configuredProjectId`'s single-line default. */ readonly configContents?: string; @@ -527,9 +531,10 @@ const setup = Effect.fnUntraced(function* (opts: SetupOpts = {}) { dockerRunLayer.pipe(Layer.provide(child.layer), Layer.provide(mockProcessControl().layer)), mockProcessControl().layer, mockRuntimeInfo({ platform: "linux" }), - Layer.succeed(CommandPlatformApiFactory, { - make: Effect.die("CommandPlatformApiFactory should not be used by a local start"), - }), + opts.platformApiFactoryLayer ?? + Layer.succeed(CommandPlatformApiFactory, { + make: Effect.die("CommandPlatformApiFactory should not be used by a local start"), + }), Layer.succeed(CliArgs, { args: ["start"] }), Layer.succeed(DebugFlag, false), Layer.succeed(YesFlag, false), @@ -2631,14 +2636,22 @@ content_path = "./supabase/templates/custom_notice.html" it.effect("seeds a configured bucket on a fresh volume with storage enabled", () => Effect.gen(function* () { const http = mockStorageBucketHttpClient(); + const api = mockCommandPlatformApi(); const { layer } = yield* setup({ configContents: 'project_id = "demo"\n[storage.buckets.avatars]\npublic = false\n', route: freshVolumeRoute(defaultRoute()), httpClientLayer: http.layer, + platformApiFactoryLayer: api.factoryLayer, }); yield* start(flags({ exclude: ["edge-runtime"] })).pipe(Effect.provide(layer)); expect(http.createdBucketRequests).toHaveLength(1); + assertPermissionDrift({ + command: "start", + requests: [...http.requests, ...api.requests], + activeFlags: { exclude: true }, + exact: true, + }); // docker.io Storage carries its own Docker healthcheck, so readiness // never goes through the gateway. expect(http.requests.some((entry) => entry.url.includes("/storage/v1/status"))).toBe(false); diff --git a/apps/cli/src/commands/status/status.command.ts b/apps/cli/src/commands/status/status.command.ts index 8d0c6e77c0..d39b93d3e2 100644 --- a/apps/cli/src/commands/status/status.command.ts +++ b/apps/cli/src/commands/status/status.command.ts @@ -2,6 +2,9 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withContext } from "../../command-internal/command-permissions/model.ts"; +import { branchTarget } from "../../command-internal/branch-target.ts"; import { commandCredentialsLayer } from "../../auth/command-credentials.layer.ts"; import { commandPlatformApiFactoryLayer } from "../../auth/command-platform-api-factory.layer.ts"; import { commandSettingsLayer } from "../../config/command-settings.layer.ts"; @@ -40,9 +43,9 @@ const config = { export type StatusFlags = CliCommand.Command.Config.Infer; -// `status` makes no Management API calls, so it avoids `managementApiRuntimeLayer` — the eager -// `CommandPlatformApi` stack that resolves a token at layer build time and fails outright -// without one. +// `status` avoids `managementApiRuntimeLayer` — the eager `CommandPlatformApi` stack that +// resolves a token at layer build time and fails outright without one — because its only +// Management API call is the lazy best-effort branch lookup below. const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); // Lazy Management API handle for `resolveLinkedState`'s best-effort branch-name lookup: @@ -81,6 +84,15 @@ export const statusCommand = Command.make("status", config).pipe( description: "Output status as JSON", }, ]), + withPermissions( + compose( + { operations: [], noApiEffectFlags: ["override-name", "exclude", "ignore-health-check"] }, + withContext( + branchTarget, + "the linked project ref (SUPABASE_PROJECT_ID or supabase/.temp/project-ref) differs from the parent ref in supabase/.temp/linked-project.json, and an access token resolves", + ), + ), + ), Command.withHandler((flags) => status(flags).pipe( withCommandTelemetry({ diff --git a/apps/cli/src/commands/status/status.integration.test.ts b/apps/cli/src/commands/status/status.integration.test.ts index 0b9d3cfeb2..c33b4f45e8 100644 --- a/apps/cli/src/commands/status/status.integration.test.ts +++ b/apps/cli/src/commands/status/status.integration.test.ts @@ -28,11 +28,13 @@ import { statusCodeFailure, transportFailure, mockCommandSettings, + mockCommandPlatformApi, mockCommandPlatformApiService, mockTelemetryStateTracked, useTempWorkdir, withEnvVar, } from "../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; import type { CommandPlatformApiFactoryError } from "../../auth/command-platform-api-factory.service.ts"; import { CommandPlatformApiFactory } from "../../auth/command-platform-api-factory.service.ts"; import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts"; @@ -319,6 +321,8 @@ interface SetupOpts { readonly fail?: unknown; readonly makeFails?: CommandPlatformApiFactoryError; }; + /** A ready-made `CommandPlatformApiFactory` layer, e.g. one that records HTTP requests. */ + readonly apiFactoryLayer?: Layer.Layer; /** `SUPABASE_PROJECT_ID` for the linked-state chain — defaults to unset. */ readonly projectId?: Option.Option; } @@ -359,6 +363,7 @@ function setup(opts: SetupOpts = {}) { Layer.succeed(OutputFlag, opts.goOutput ?? Option.none()), ...(apiMock === undefined ? [] : [apiMock.layer]), ...(apiFactoryMock === undefined ? [] : [apiFactoryMock.layer]), + ...(opts.apiFactoryLayer === undefined ? [] : [opts.apiFactoryLayer]), ); return { workdir, out, telemetry, child, layer, apiMock, apiFactoryMock }; @@ -1531,6 +1536,35 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); + it.effect.each([ + { + answer: "200", + response: { status: 200, body: [LINKED_BRANCH] }, + branchLine: ` Branch: feature-x (${LINKED_BRANCH_REF})\n`, + }, + { + answer: "403", + response: { status: 403, body: { message: "forbidden" } }, + branchLine: ` Branch: ${LINKED_BRANCH_REF}\n`, + }, + ])("branch-linked: the branch lookup answers $answer over HTTP", ({ response, branchLine }) => { + const api = mockCommandPlatformApi({ response }); + const { layer, out, workdir } = setup({ apiFactoryLayer: api.factoryLayer }); + return Effect.gen(function* () { + yield* writeConfig(); + yield* writeProjectRefFile(workdir, LINKED_BRANCH_REF); + yield* writeLinkedProjectCacheFile(workdir, LINKED_PARENT_REF, { + name: "Parent Project", + }); + yield* status(flags()); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${LINKED_PARENT_REF}/branches`, + ]); + expect(out.stdoutText).toContain(branchLine); + assertPermissionDrift({ command: "status", requests: api.requests }); + }).pipe(Effect.provide(layer)); + }); + it.effect( "branch-linked with the branch lookup failing (status error): RICH degraded block, still succeeds", () => { diff --git a/apps/cli/src/commands/stop/stop.command.ts b/apps/cli/src/commands/stop/stop.command.ts index 4b1c28a03a..43f0e140df 100644 --- a/apps/cli/src/commands/stop/stop.command.ts +++ b/apps/cli/src/commands/stop/stop.command.ts @@ -2,6 +2,7 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { commandSettingsLayer } from "../../config/command-settings.layer.ts"; @@ -50,6 +51,10 @@ const stopRuntimeLayer = Layer.mergeAll( export const stopCommand = Command.make("stop", config).pipe( Command.withDescription("Stop all local Supabase containers."), Command.withShortDescription("Stop all local Supabase containers"), + withPermissions({ + operations: [], + noApiEffectFlags: ["project-id", "backup", "no-backup", "all"], + }), Command.withHandler((flags) => stop(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/tests/helpers/permission-pending-commands.ts b/apps/cli/tests/helpers/permission-pending-commands.ts index 9e2f4cec2a..e552d7a766 100644 --- a/apps/cli/tests/helpers/permission-pending-commands.ts +++ b/apps/cli/tests/helpers/permission-pending-commands.ts @@ -7,21 +7,4 @@ export const PENDING_COMMANDS: ReadonlyArray = [ "db remote changes", "gen keys", - - "init", - - "stack destroy", - "stack list", - "stack logs", - "stack prepare", - "stack restart", - "stack start", - "stack status", - "stack stop", - - "start", - - "status", - - "stop", ]; diff --git a/apps/cli/tests/helpers/permission-registry.unit.test.ts b/apps/cli/tests/helpers/permission-registry.unit.test.ts index 3854f08176..c0d654372d 100644 --- a/apps/cli/tests/helpers/permission-registry.unit.test.ts +++ b/apps/cli/tests/helpers/permission-registry.unit.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from "vitest"; import type { CommandPermissions } from "../../src/command-internal/command-permissions/model.ts"; +import { commandTreeFor } from "./command-flags.ts"; +import { PENDING_COMMANDS } from "./permission-pending-commands.ts"; import { applicableOperations, declaredPermissions } from "./permission-registry.ts"; describe("applicableOperations", () => { @@ -154,11 +156,19 @@ describe("applicableOperations", () => { }); describe("declaredPermissions", () => { - it("tells a path missing from the tree apart from a command with no declaration", () => { + it("finds a declared leaf only in the tree that includes it, and reports an absent path as not found", () => { expect(declaredPermissions("not-a-real-command")).toEqual({ _tag: "NotFound" }); expect(declaredPermissions("stack list")).toEqual({ _tag: "NotFound" }); - expect(declaredPermissions("stack list", { stackBackend: "stack" })).toEqual({ - _tag: "Undeclared", - }); + expect(declaredPermissions("stack list", { stackBackend: "stack" })._tag).toBe("Declared"); + }); + + it("reports a command in the tree that has no declaration as undeclared", () => { + const tree = commandTreeFor({}); + const pendingInTree = PENDING_COMMANDS.filter((path) => tree.has(path)); + // Fails when the last pending command is mapped; delete this test then. + expect(pendingInTree.length).toBeGreaterThan(0); + for (const path of pendingInTree) { + expect(declaredPermissions(path)).toEqual({ _tag: "Undeclared" }); + } }); }); From 313639e6cef6c9534a6266bd5b505295fd721bfc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:40:30 +0000 Subject: [PATCH 20/26] chore(repo): format the develop-into-next merge --- apps/cli/src/commands/secrets/unset/unset.command.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/commands/secrets/unset/unset.command.ts b/apps/cli/src/commands/secrets/unset/unset.command.ts index 274b3931e2..a66a9defab 100644 --- a/apps/cli/src/commands/secrets/unset/unset.command.ts +++ b/apps/cli/src/commands/secrets/unset/unset.command.ts @@ -60,7 +60,11 @@ export const secretsUnsetCommand = Command.make("unset", config).pipe( compose( { operations: [ - { operationId: "v1-list-all-secrets", kind: "required", when: [{ flag: "all", is: true }] }, + { + operationId: "v1-list-all-secrets", + kind: "required", + when: [{ flag: "all", is: true }], + }, { operationId: "v1-bulk-delete-secrets", kind: "required", From 84193eacebd308aeb7948d54ac8510afd5528862 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:42:05 +0000 Subject: [PATCH 21/26] chore(repo): fix the develop-into-next merge --- .../config/diff/diff.integration.test.ts | 19 +++++++++++-------- .../deploy/deploy.integration.test.ts | 2 ++ .../gen/types/types.integration.test.ts | 1 + .../commands/link/link.integration.test.ts | 19 +++++++++++-------- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/commands/config/diff/diff.integration.test.ts b/apps/cli/src/commands/config/diff/diff.integration.test.ts index 9eba1513d0..52a82fdf8a 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -1373,16 +1373,19 @@ describe("config diff integration", () => { workdir: tempRoot.current, projectId: Option.none(), }); - const layer = buildTestRuntime({ - out, - api, - cliSettings, - tty: mockTty({ stdinIsTty: true }), - linkedProjectCache: realLinkedProjectCache({ - httpClient: api.httpClientLayer, + const layer = Layer.merge( + buildTestRuntime({ + out, + api, cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), }), - }); + cliConfigValuesTestLayer, + ); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; yield* writeProjectFile("config.toml", 'project_id = "test"\n'); diff --git a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts index b6bd40df5d..6847eb1edc 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -1248,6 +1248,7 @@ describe("functions deploy", () => { }); const child = mockDockerBundleSpawner(); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, @@ -1533,6 +1534,7 @@ describe("functions deploy", () => { }, }); const layer = Layer.mergeAll( + cliConfigValuesTestLayer, buildTestRuntime({ out, api, diff --git a/apps/cli/src/commands/gen/types/types.integration.test.ts b/apps/cli/src/commands/gen/types/types.integration.test.ts index 32d171d0c9..e87f133c0f 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -3153,6 +3153,7 @@ const setupRecorded = Effect.fnUntraced(function* (opts: { }); const layer = Layer.mergeAll( runtime, + cliConfigValuesTestLayer, BunServices.layer, mockInspectSpawner().layer, Stdio.layerTest({ args: Effect.succeed(opts.args) }), diff --git a/apps/cli/src/commands/link/link.integration.test.ts b/apps/cli/src/commands/link/link.integration.test.ts index 6f5f9771d8..004f70ebd3 100644 --- a/apps/cli/src/commands/link/link.integration.test.ts +++ b/apps/cli/src/commands/link/link.integration.test.ts @@ -1576,15 +1576,18 @@ describe("link permissions", () => { return jsonResponse(request, 404, { message: "unexpected request" }); }), }); - const layer = buildTestRuntime({ - out, - api, - cliSettings: mockCommandSettings({ - workdir: tempRoot.current, - projectId: Option.none(), + const layer = Layer.merge( + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }), + tty: mockTty({ stdinIsTty: true }), }), - tty: mockTty({ stdinIsTty: true }), - }); + cliConfigValuesTestLayer, + ); return { layer, api, out, workdir: tempRoot.current }; } From 35ee8bc20957c47af93316029d55e392368e805d Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Thu, 15 Oct 2026 12:39:50 +0000 Subject: [PATCH 22/26] ci(repo): add flaky-test check workflow (#7058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary **TL;DR:** A new `Flaky check` workflow runs one commit on many fresh runners with retries off and reports every test that both passed and failed. It runs nightly on `develop`, weekly for e2e, and on demand through the `run-flaky-check` PR label, so PRs that fix flaky tests can show the fix holds. ```mermaid flowchart LR T["nightly develop / weekly e2e
run-flaky-check label / dispatch"] --> P["plan: commit, suites, N runs"] P --> M["N fresh runners per suite
retries off, JUnit output"] M --> R["report: flaky / always failing
runs without results"] R --> O["artifact + job summary
PR comment"] ``` ### Why Flaky tests surface one at a time in unrelated PRs and get retried away. There is no way to ask "is this commit stable?" or to show that a flaky-test fix actually holds. `stack-stress.yml` covers two stack scenarios only. ### What changed - `flaky-check.yml` runs: - nightly: unit + integration ×10 on `develop` - weekly: CLI e2e (4 shards + `cli-e2e`) and stack whole-stack e2e ×3 - `run-flaky-check` label: unit + integration ×10 on the PR merge commit, plus a `focused` suite that repeats the tests the PR affects (`vitest --changed` against the merge base, `--repeats=4`, ×10 runs) - manual dispatch: any ref, suites, run count, repeats, and file filter - The label is removed when the run starts, so re-adding it starts another run. - A report job classifies each test as flaky (mixed results) or always failing. It also lists runs that produced no usable results: timeouts, crashes, missing JUnit reports, or missing uploads. Each failed `--repeats` execution counts separately. - The report goes to the job summary, a `flaky-check-report` artifact (`report.md`, `report.json`), and one sticky PR comment. The report job fails when it finds anything. - `.github/MAINTAINERS.md` documents the triggers and the `focused` caveats. ### Linked issue None; maintainer change. The `run-flaky-check` label already exists. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 --- .github/MAINTAINERS.md | 26 ++ .github/scripts/flaky-check/cli.ts | 291 ++++++++++++++ .../flaky-check/fixtures/iteration-1.json | 60 +++ .../flaky-check/fixtures/iteration-2.json | 60 +++ .github/scripts/flaky-check/plan.test.ts | 179 +++++++++ .github/scripts/flaky-check/plan.ts | 302 ++++++++++++++ .github/scripts/flaky-check/report.test.ts | 296 ++++++++++++++ .github/scripts/flaky-check/report.ts | 368 ++++++++++++++++++ .github/workflows/flaky-check.yml | 244 ++++++++++++ .github/workflows/github-scripts-ci.yml | 1 + .gitignore | 2 + knip.jsonc | 5 + 12 files changed, 1834 insertions(+) create mode 100644 .github/scripts/flaky-check/cli.ts create mode 100644 .github/scripts/flaky-check/fixtures/iteration-1.json create mode 100644 .github/scripts/flaky-check/fixtures/iteration-2.json create mode 100644 .github/scripts/flaky-check/plan.test.ts create mode 100644 .github/scripts/flaky-check/plan.ts create mode 100644 .github/scripts/flaky-check/report.test.ts create mode 100644 .github/scripts/flaky-check/report.ts create mode 100644 .github/workflows/flaky-check.yml diff --git a/.github/MAINTAINERS.md b/.github/MAINTAINERS.md index c0eba167bb..25c044e909 100644 --- a/.github/MAINTAINERS.md +++ b/.github/MAINTAINERS.md @@ -83,6 +83,32 @@ the preview. This is independent of `run-ci` and `run-live-e2e-ci`. The `run-preview-packages` label must exist as a repository label; create it from **Issues → Labels** if it is missing. +## `run-flaky-check`: repeat the suites to find flaky tests + +[`flaky-check.yml`](./workflows/flaky-check.yml) runs one commit on many fresh +runners with retries off. A test that passes in some runs and fails in others is +reported as flaky; one that fails in every run is reported separately. + +| Trigger | Commit | Suites | +| ------------------------ | --------------- | --------------------------------------------- | +| Nightly, 03:17 UTC | `develop` | unit, integration ×10 | +| Weekly, Sunday 03:47 UTC | `develop` | CLI e2e, stack e2e ×3 | +| `run-flaky-check` label | PR merge commit | unit, integration ×10, plus `focused` | +| Manual dispatch | any ref | chosen suites, runs, repeats, and file filter | + +`focused` runs the tests affected by the PR (`vitest --changed` against the merge +base) five times per job, each in a fresh Vitest process, to show a flaky-test fix +holds; a changed `package.json` or Vitest config selects every test. Dispatch with +`suites=focused` and a `filter` to target specific files. + +The label is removed when the run starts, so re-add it to run again (by hand on +fork PRs, which also get no comment). The report goes to the job summary, the +`flaky-check-report` artifact, and one PR comment, and fails the run when any test +failed or any run produced no results. + +The `run-flaky-check` label must exist as a repository label; create it from +**Issues → Labels** if it is missing. + ## Live e2e coverage and stable releases [`Live E2E`](./workflows/live-e2e.yml) exercises managed staging after every push diff --git a/.github/scripts/flaky-check/cli.ts b/.github/scripts/flaky-check/cli.ts new file mode 100644 index 0000000000..979fa17b77 --- /dev/null +++ b/.github/scripts/flaky-check/cli.ts @@ -0,0 +1,291 @@ +import { + appendFileSync, + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, join } from "node:path"; +import type { JsonTestResults } from "vitest/node"; +import { + CheckError, + collectedName, + commandsFor, + type Entry, + plan, + RESULTS_DIR, + type ReportSource, +} from "./plan.ts"; +import { + aggregate, + COMMENT_MARKER, + commentBody, + isClean, + parseVitestJson, + renderMarkdown, + type RunMeta, + type RunResult, +} from "./report.ts"; + +function env(name: string): string { + return process.env[name] ?? ""; +} + +function setOutputs(values: Record): void { + const lines = Object.entries(values).map(([key, value]) => `${key}=${value}\n`); + appendFileSync(env("GITHUB_OUTPUT") || "/dev/stdout", lines.join("")); +} + +function capture(argv: string[]): string { + const result = Bun.spawnSync(argv, { stderr: "inherit" }); + if (result.exitCode !== 0) { + throw new CheckError(`${argv.join(" ")} exited ${result.exitCode}`); + } + return result.stdout.toString().trim(); +} + +function words(value: string): string[] { + return value.split(/\s+/).filter((word) => word !== ""); +} + +function planCommand(): void { + const { entries, base, filter } = plan({ + event: env("EVENT_NAME"), + schedule: env("SCHEDULE"), + baseRef: env("PR_BASE_REF"), + suites: env("INPUT_SUITES"), + runs: env("INPUT_RUNS"), + repeats: env("INPUT_REPEATS"), + filter: env("INPUT_FILTER"), + }); + // github.sha is already the commit to test (a PR's merge commit, or the branch head), unless a + // dispatch names another ref. + const ref = env("INPUT_REF"); + const sha = + ref === "" + ? env("EVENT_SHA") + : capture([ + "gh", + "api", + `repos/${env("GITHUB_REPOSITORY")}/commits/${encodeURIComponent(ref)}`, + "--jq", + ".sha", + ]); + setOutputs({ sha, base, filter, matrix: JSON.stringify({ include: entries }) }); +} + +type TurboTask = { task: string; package: string; directory: string; command: string }; + +/** Collected report names an invocation should produce, so a report that never appears is flagged. */ +function expectedReports(source: ReportSource, turboCache: Map): string[] { + if ("dir" in source) { + return [collectedName(source.dir, source.report)]; + } + const key = source.turbo.join(" "); + let tasks = turboCache.get(key); + if (tasks === undefined) { + const dryRun: { tasks: TurboTask[] } = JSON.parse( + capture(["pnpm", "exec", "turbo", "run", ...source.turbo, "--dry=json"]), + ); + tasks = dryRun.tasks.filter( + (task) => task.task === source.turbo[0] && task.command !== "", + ); + turboCache.set(key, tasks); + } + return tasks.map((task) => { + // Turbo forwards arguments to the last command of a chained script only. + if (task.command.includes("&&")) { + throw new CheckError( + `${task.package} chains commands in ${task.task}, so only its last one would write a report`, + ); + } + return collectedName(task.directory, source.report); + }); +} + +async function runCommand(): Promise { + const entry: Entry = JSON.parse(env("MATRIX")); + const filters = words(env("FILTER")); + const extra = + entry.suite === "focused" && filters.length === 0 + ? ["--changed", capture(["git", "merge-base", "HEAD", `origin/${env("BASE_REF")}`])] + : filters; + if (entry.suite === "e2e") { + process.env.SUPABASE_GO_BINARY = join(process.cwd(), "apps/cli-go/supabase-go"); + } + if (entry.suite === "e2e" && entry.target === "cli") { + // Matches test.yml, so concurrency-sensitive flakes show up here too. + process.env.SUPABASE_E2E_WORKERS = "4"; + } + let exitCode = 0; + const expected: string[] = []; + const failedInvocations: RunMeta["failedInvocations"] = []; + const turboCache = new Map(); + for (let iteration = 1; iteration <= entry.executions; iteration += 1) { + for (const { argv, reports } of commandsFor(entry, iteration, extra)) { + const names = expectedReports(reports, turboCache); + expected.push(...names); + console.log(`$ ${argv.join(" ")}`); + const code = await Bun.spawn(argv, { stdout: "inherit", stderr: "inherit" }).exited; + if (code !== 0) { + exitCode = code; + failedInvocations.push({ reports: names, exitCode: code }); + } + } + } + setOutputs({ + "exit-code": String(exitCode), + "expected-reports": JSON.stringify(expected), + "failed-invocations": JSON.stringify(failedInvocations), + }); + return exitCode; +} + +function collectCommand(): void { + const entry: Entry = JSON.parse(env("MATRIX")); + const out = join(env("RUNNER_TEMP"), "flaky-results", entry.name); + mkdirSync(out, { recursive: true }); + for (const report of new Bun.Glob(`{apps,packages}/*/${RESULTS_DIR}/*.json`).scanSync({ + dot: true, + })) { + copyFileSync( + report, + join(out, collectedName(dirname(dirname(report)), basename(report, ".json"))), + ); + } + const meta: RunMeta = { + name: entry.name, + suite: entry.suite, + run: entry.run, + executions: entry.executions, + exitCode: env("EXIT_CODE") === "" ? null : Number(env("EXIT_CODE")), + root: process.cwd(), + expectedReports: JSON.parse(env("EXPECTED_REPORTS") || "[]"), + failedInvocations: JSON.parse(env("FAILED_INVOCATIONS") || "[]"), + }; + writeFileSync(join(out, "meta.json"), `${JSON.stringify(meta, null, 2)}\n`); + setOutputs({ dir: out }); +} + +/** A missing, truncated, or foreign report reads as absent, which the aggregation flags. */ +function readCases(path: string, root: string): ReturnType | undefined { + try { + const results: JsonTestResults = JSON.parse(readFileSync(path, "utf8")); + return parseVitestJson(results, root); + } catch { + return undefined; + } +} + +function readResults(dir: string): RunResult[] { + if (!existsSync(dir)) { + return []; + } + return readdirSync(dir) + .filter((name) => existsSync(join(dir, name, "meta.json"))) + .map((name) => { + const runDir = join(dir, name); + const meta: RunMeta = JSON.parse(readFileSync(join(runDir, "meta.json"), "utf8")); + const reports = readdirSync(runDir) + .filter((file) => file.endsWith(".json") && file !== "meta.json") + .flatMap((file) => { + const cases = readCases(join(runDir, file), meta.root); + const iteration = Number(/\.(\d+)\.json$/.exec(file)?.[1] ?? "1"); + return cases === undefined ? [] : [{ name: file, iteration, cases }]; + }); + return { meta, reports }; + }); +} + +function reportCommand(): void { + const [resultsDir = "results", outDir = "flaky-report"] = process.argv.slice(3); + const matrix: { include: Entry[] } = JSON.parse(env("MATRIX") || '{"include":[]}'); + const report = aggregate( + readResults(resultsDir), + matrix.include.map((entry) => entry.name), + env("FILTER") === "" ? ["focused"] : [], + ); + const markdown = renderMarkdown(report, { + sha: env("SHA"), + runUrl: `${env("GITHUB_SERVER_URL")}/${env("GITHUB_REPOSITORY")}/actions/runs/${env("GITHUB_RUN_ID")}`, + }); + mkdirSync(outDir, { recursive: true }); + writeFileSync(join(outDir, "report.json"), `${JSON.stringify(report, null, 2)}\n`); + writeFileSync(join(outDir, "report.md"), markdown); + appendFileSync(env("GITHUB_STEP_SUMMARY") || "/dev/stdout", markdown); + if (!isClean(report)) { + console.log( + "::error ::Flaky, failing, or missing runs found; see the job summary or the flaky-check-report artifact.", + ); + process.exitCode = 1; + } +} + +/** Creates or updates the one PR comment that starts with the report marker. */ +function commentCommand(): void { + const [reportPath = "flaky-report/report.md"] = process.argv.slice(3); + const issue = `repos/${env("GITHUB_REPOSITORY")}/issues`; + try { + const pages: { id: number; body: string; user: { login: string } }[][] = JSON.parse( + capture(["gh", "api", "--paginate", "--slurp", `${issue}/${env("PR_NUMBER")}/comments`]), + ); + const existing = pages + .flat() + .find( + (comment) => + comment.user.login === "github-actions[bot]" && comment.body.startsWith(COMMENT_MARKER), + ); + const target = + existing === undefined + ? ["POST", `${issue}/${env("PR_NUMBER")}/comments`] + : ["PATCH", `${issue}/comments/${existing.id}`]; + capture([ + "gh", + "api", + "--method", + ...target, + "--raw-field", + `body=${commentBody(readFileSync(reportPath, "utf8"))}`, + ]); + } catch (error) { + // The report stays in the job summary and artifact, so a comment failure only warns. + console.log( + `::warning::Unable to post the flaky-check PR comment: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +if (import.meta.main) { + const command = process.argv[2]; + try { + switch (command) { + case "plan": + planCommand(); + break; + case "run": + process.exitCode = await runCommand(); + break; + case "collect": + collectCommand(); + break; + case "report": + reportCommand(); + break; + case "comment": + commentCommand(); + break; + default: + throw new CheckError( + `usage: cli.ts plan|run|collect|report|comment, got '${command ?? ""}'`, + ); + } + } catch (error) { + if (!(error instanceof CheckError)) { + throw error; + } + console.log(`::error ::${error.message}`); + process.exitCode = 1; + } +} diff --git a/.github/scripts/flaky-check/fixtures/iteration-1.json b/.github/scripts/flaky-check/fixtures/iteration-1.json new file mode 100644 index 0000000000..7a8ba14f1d --- /dev/null +++ b/.github/scripts/flaky-check/fixtures/iteration-1.json @@ -0,0 +1,60 @@ +{ + "testResults": [ + { + "name": "/repo/packages/api/src/hook.unit.test.ts", + "status": "passed", + "message": "", + "assertionResults": [ + { + "ancestorTitles": ["hook"], + "title": "runs after the hook", + "status": "passed", + "failureMessages": [] + } + ] + }, + { + "name": "/repo/packages/api/src/import-error.unit.test.ts", + "status": "failed", + "message": "import failure", + "assertionResults": [] + }, + { + "name": "/repo/packages/api/src/probe.unit.test.ts", + "status": "failed", + "message": "", + "assertionResults": [ + { + "ancestorTitles": ["sometimes"], + "title": "fails on the second iteration", + "status": "passed", + "failureMessages": [] + }, + { + "ancestorTitles": ["two errors"], + "title": "test and afterEach both fail on the second iteration", + "status": "passed", + "failureMessages": [] + }, + { + "ancestorTitles": ["@supabase/api"], + "title": "twin", + "status": "passed", + "failureMessages": [] + }, + { + "ancestorTitles": ["@supabase/api"], + "title": "twin", + "status": "failed", + "failureMessages": ["Error: second twin"] + }, + { + "ancestorTitles": ["@supabase/api"], + "title": "skipped", + "status": "skipped", + "failureMessages": [] + } + ] + } + ] +} diff --git a/.github/scripts/flaky-check/fixtures/iteration-2.json b/.github/scripts/flaky-check/fixtures/iteration-2.json new file mode 100644 index 0000000000..4f4c94740e --- /dev/null +++ b/.github/scripts/flaky-check/fixtures/iteration-2.json @@ -0,0 +1,60 @@ +{ + "testResults": [ + { + "name": "/repo/packages/api/src/hook.unit.test.ts", + "status": "failed", + "message": "", + "assertionResults": [ + { + "ancestorTitles": ["hook"], + "title": "runs after the hook", + "status": "skipped", + "failureMessages": [] + } + ] + }, + { + "name": "/repo/packages/api/src/import-error.unit.test.ts", + "status": "failed", + "message": "import failure", + "assertionResults": [] + }, + { + "name": "/repo/packages/api/src/probe.unit.test.ts", + "status": "failed", + "message": "", + "assertionResults": [ + { + "ancestorTitles": ["sometimes"], + "title": "fails on the second iteration", + "status": "failed", + "failureMessages": ["Error: second iteration"] + }, + { + "ancestorTitles": ["two errors"], + "title": "test and afterEach both fail on the second iteration", + "status": "failed", + "failureMessages": ["Error: test body", "Error: afterEach too"] + }, + { + "ancestorTitles": ["@supabase/api"], + "title": "twin", + "status": "passed", + "failureMessages": [] + }, + { + "ancestorTitles": ["@supabase/api"], + "title": "twin", + "status": "failed", + "failureMessages": ["Error: second twin"] + }, + { + "ancestorTitles": ["@supabase/api"], + "title": "skipped", + "status": "skipped", + "failureMessages": [] + } + ] + } + ] +} diff --git a/.github/scripts/flaky-check/plan.test.ts b/.github/scripts/flaky-check/plan.test.ts new file mode 100644 index 0000000000..83c752ecb3 --- /dev/null +++ b/.github/scripts/flaky-check/plan.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "bun:test"; +import { CheckError, commandsFor, type Entry, plan, type PlanInput, WEEKLY_CRON } from "./plan.ts"; + +const input = (overrides: Partial): PlanInput => ({ + event: "workflow_dispatch", + schedule: "", + baseRef: "", + suites: "unit", + runs: "1", + repeats: "4", + filter: "", + ...overrides, +}); + +function entry(name: string): Entry { + const found = plan(input({ suites: "unit,integration,focused,e2e,stack-e2e" })).entries.find( + (e) => e.name === name, + ); + if (found === undefined) { + throw new Error(`no plan entry ${name}`); + } + return found; +} + +function outputOf(argv: string[]): string | undefined { + return argv + .find((arg) => arg.startsWith("--outputFile.json=")) + ?.slice("--outputFile.json=".length); +} + +describe("plan", () => { + test("schedules run unit and integration nightly and the e2e suites on the workflow's weekly cron", async () => { + const workflow = await Bun.file(`${import.meta.dir}/../../workflows/flaky-check.yml`).text(); + expect(JSON.stringify(Bun.YAML.parse(workflow))).toContain(`"cron":"${WEEKLY_CRON}"`); + + const weekly = plan(input({ event: "schedule", schedule: WEEKLY_CRON })).entries; + const nightly = plan(input({ event: "schedule", schedule: "17 3 * * *" })).entries; + + expect(weekly.map((e) => e.name).slice(0, 7)).toEqual([ + "flaky-e2e-cli1-run1", + "flaky-e2e-cli2-run1", + "flaky-e2e-cli3-run1", + "flaky-e2e-cli4-run1", + "flaky-e2e-cli5-run1", + "flaky-e2e-cli-e2e1-run1", + "flaky-e2e-cli-e2e2-run1", + ]); + expect(weekly).toHaveLength(48); + expect(nightly.map((e) => [e.suite, e.runner, e.timeout]).slice(9, 11)).toEqual([ + ["unit", "blacksmith-4vcpu-ubuntu-2404", 30], + ["integration", "blacksmith-8vcpu-ubuntu-2404", 30], + ]); + expect(nightly).toHaveLength(20); + }); + + test("a labeled pull request adds focused jobs that run five executions against the PR base", () => { + const { entries, base } = plan(input({ event: "pull_request", baseRef: "next" })); + + expect(base).toBe("next"); + expect( + entries + .filter((e) => e.suite === "focused") + .map((e) => [e.executions, e.fetchDepth, e.timeout]), + ).toEqual(Array(10).fill([5, 0, 60])); + }); + + test("a dispatch normalizes its inputs and stays within GitHub's matrix limit", () => { + const { entries, filter } = plan( + input({ + suites: " focused , unit ", + runs: " 2", + repeats: "9", + filter: " src/a.ts\nsrc/b.ts ", + }), + ); + + expect(entries.map((e) => [e.name, e.executions])).toEqual([ + ["flaky-unit-run1", 1], + ["flaky-unit-run2", 1], + ["flaky-focused-run1", 10], + ["flaky-focused-run2", 10], + ]); + expect(filter).toBe("src/a.ts src/b.ts"); + expect(() => + plan(input({ suites: "unit,integration,focused,e2e,stack-e2e", runs: "30" })), + ).toThrow("570 jobs exceed GitHub's 256-job matrix limit"); + }); + + test.each([ + [{ suites: "unit,bogus" }, "unknown suite 'bogus'"], + [{ suites: " , " }, "no suites selected"], + [{ runs: "1.5" }, "runs must be a whole number between 1 and 30, got '1.5'"], + ])("rejects %j", (overrides, message) => { + expect(() => plan(input(overrides))).toThrow(CheckError); + expect(() => plan(input(overrides))).toThrow(message); + }); +}); + +describe("commandsFor", () => { + test("each focused iteration runs both @supabase/api projects outside Turbo, with its own reports", () => { + const invocations = commandsFor(entry("flaky-focused-run1"), 3, ["--changed", "abc123"]); + + expect(invocations.map((i) => [i.reports, outputOf(i.argv)])).toEqual([ + [ + { turbo: ["test:unit:run", "--filter=!@supabase/cli-go"], report: "unit.3" }, + ".flaky-results/unit.3.json", + ], + [ + { turbo: ["test:integration:run", "--filter=!@supabase/api"], report: "integration.3" }, + ".flaky-results/integration.3.json", + ], + [ + { dir: "packages/api", report: "integration-node.3" }, + ".flaky-results/integration-node.3.json", + ], + [ + { dir: "packages/api", report: "integration-bun.3" }, + ".flaky-results/integration-bun.3.json", + ], + ]); + expect(invocations[3]?.argv.slice(0, 10)).toEqual([ + "pnpm", + "--dir", + "packages/api", + "exec", + "bun", + "--bun", + "vitest", + "run", + "--project", + "bun-integration", + ]); + for (const { argv } of invocations) { + expect(argv.slice(-2)).toEqual(["--changed", "abc123"]); + } + }); + + test.each([ + [ + "flaky-e2e-cli2-run1", + [{ turbo: ["test:e2e:run", "--only", "--filter=supabase"], report: "e2e.1" }], + "--shard=2/5", + ], + [ + "flaky-e2e-cli-e2e2-run1", + [{ turbo: ["test:e2e:run", "--only", "--filter=@supabase/cli-e2e"], report: "e2e.1" }], + "--shard=2/2", + ], + [ + "flaky-stack-e2e-native-lifecycle-run1", + [ + { dir: "packages/stack", report: "whole-stack.native.lifecycle.1" }, + { dir: "packages/stack", report: "public.1" }, + { dir: "packages/stack", report: "namespace.1" }, + { dir: "packages/stack", report: "port-reservation.1" }, + ], + "src/whole-stack.native.lifecycle.e2e.test.ts", + ], + [ + "flaky-stack-e2e-docker-confinement-run1", + [{ dir: "packages/stack", report: "whole-stack.docker.confinement.1" }], + "--testNamePattern=writes stay confined", + ], + [ + "flaky-stack-e2e-podman-idle-parallel-run1", + [{ dir: "packages/stack", report: "whole-stack.podman.idle-parallel.1" }], + "src/whole-stack.podman.idle-parallel.e2e.test.ts", + ], + ])("%s declares where each report lands", (name, reports, arg) => { + const invocations = commandsFor(entry(name), 1, ["ignored-filter"]); + + expect(invocations.map((i) => i.reports)).toEqual(reports); + expect(invocations.map((i) => outputOf(i.argv))).toEqual( + reports.map((r) => `.flaky-results/${r.report}.json`), + ); + expect(invocations[0]?.argv).toContain(arg); + expect(invocations[0]?.argv).not.toContain("ignored-filter"); + }); +}); diff --git a/.github/scripts/flaky-check/plan.ts b/.github/scripts/flaky-check/plan.ts new file mode 100644 index 0000000000..df37d4e705 --- /dev/null +++ b/.github/scripts/flaky-check/plan.ts @@ -0,0 +1,302 @@ +export const WEEKLY_CRON = "47 3 * * 0"; +/** Directory, relative to each Vitest root, where every run writes its JSON reports. */ +export const RESULTS_DIR = ".flaky-results"; +const SUITES = ["unit", "integration", "focused", "e2e", "stack-e2e"] as const; +type Suite = (typeof SUITES)[number]; +const CLI_E2E_SHARDS = 5; +const CLI_E2E_PACKAGE_SHARDS = 2; +// GitHub rejects a matrix with more jobs than this. +const MAX_JOBS = 256; + +/** One matrix job; the workflow reads its runner, timeout, and checkout depth. */ +export type Entry = { + name: string; + run: number; + /** Executions of the suite in this job, each a separate Vitest process. */ + executions: number; + runner: string; + timeout: number; + fetchDepth: number; +} & ( + | { suite: "unit" | "integration" | "focused" } + | { suite: "e2e"; target: "cli" | "cli-e2e"; shard: number } + | { + suite: "stack-e2e"; + runtime: "native" | "docker" | "podman"; + scenario: "lifecycle" | "confinement" | "idle-parallel"; + } +); + +export type PlanInput = { + event: string; + schedule: string; + baseRef: string; + suites: string; + runs: string; + repeats: string; + filter: string; +}; + +export class CheckError extends Error {} + +function count(value: string, label: string, max: number, min = 0): number { + const parsed = Number(value.trim()); + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new CheckError( + `${label} must be a whole number between ${min} and ${max}, got '${value}'`, + ); + } + return parsed; +} + +function parseSuites(value: string): Suite[] { + const names = value + .split(",") + .map((name) => name.trim()) + .filter((name) => name !== ""); + const unknown = names.find((name) => !SUITES.some((suite) => suite === name)); + if (unknown !== undefined) { + throw new CheckError(`unknown suite '${unknown}'; choose from ${SUITES.join(",")}`); + } + const suites = SUITES.filter((suite) => names.includes(suite)); + if (suites.length === 0) { + throw new CheckError(`no suites selected; choose from ${SUITES.join(",")}`); + } + return suites; +} + +function range(length: number): number[] { + return Array.from({ length }, (_, index) => index + 1); +} + +function entriesFor(suite: Suite, run: number, repeats: number): Entry[] { + const job = { + run, + executions: 1, + runner: "blacksmith-8vcpu-ubuntu-2404", + timeout: 30, + fetchDepth: 1, + }; + switch (suite) { + case "unit": + return [ + { ...job, suite, name: `flaky-unit-run${run}`, runner: "blacksmith-4vcpu-ubuntu-2404" }, + ]; + case "integration": + return [{ ...job, suite, name: `flaky-integration-run${run}` }]; + case "focused": + // A full history lets focused runs diff against the merge base. + return [ + { + ...job, + suite, + name: `flaky-focused-run${run}`, + executions: repeats + 1, + // GitHub caps a job at 360 minutes. + timeout: Math.min(360, 30 + 6 * (repeats + 1)), + fetchDepth: 0, + }, + ]; + case "e2e": { + const e2e = { ...job, suite, timeout: 45, fetchDepth: 0 }; + return [ + ...range(CLI_E2E_SHARDS).map((shard) => ({ + ...e2e, + target: "cli" as const, + shard, + name: `flaky-e2e-cli${shard}-run${run}`, + })), + ...range(CLI_E2E_PACKAGE_SHARDS).map((shard) => ({ + ...e2e, + target: "cli-e2e" as const, + shard, + name: `flaky-e2e-cli-e2e${shard}-run${run}`, + })), + ]; + } + case "stack-e2e": + return (["native", "docker", "podman"] as const).flatMap((runtime) => + (["lifecycle", "confinement", "idle-parallel"] as const).map((scenario) => ({ + ...job, + suite, + runtime, + scenario, + timeout: 45, + name: `flaky-stack-e2e-${runtime}-${scenario}-run${run}`, + })), + ); + } +} + +/** Matrix jobs for a trigger; `base` is the branch focused runs diff against. */ +export function plan(input: PlanInput): { entries: Entry[]; base: string; filter: string } { + let suites: Suite[] = ["unit", "integration"]; + let runs = 10; + let repeats = 4; + let filter = ""; + let base = "develop"; + if (input.event === "workflow_dispatch") { + suites = parseSuites(input.suites); + runs = count(input.runs, "runs", 30, 1); + repeats = count(input.repeats, "repeats", 100); + filter = input.filter.trim().replace(/\s+/g, " "); + } else if (input.event === "pull_request") { + suites = ["unit", "integration", "focused"]; + base = input.baseRef; + } else if (input.schedule === WEEKLY_CRON) { + suites = ["e2e", "stack-e2e"]; + runs = 3; + } + + const entries = suites.flatMap((suite) => + range(runs).flatMap((run) => entriesFor(suite, run, repeats)), + ); + if (entries.length > MAX_JOBS) { + throw new CheckError( + `${entries.length} jobs exceed GitHub's ${MAX_JOBS}-job matrix limit; lower runs`, + ); + } + return { entries, base, filter }; +} + +/** + * Where an invocation's report lands: one package directory, or every package that runs a Turbo + * task (`turbo` holds the task and its filters, resolved with `turbo run --dry=json`). + */ +export type ReportSource = { report: string } & ({ dir: string } | { turbo: string[] }); + +type Invocation = { argv: string[]; reports: ReportSource }; + +/** The name a collected report gets: `apps/cli` + `unit.1` becomes `apps__cli--unit.1.json`. */ +export function collectedName(dir: string, report: string): string { + return `${dir.replaceAll("/", "__")}--${report}.json`; +} + +function reporter(report: string): string[] { + return [ + "--retry=0", + "--reporter=default", + "--reporter=json", + `--outputFile.json=${RESULTS_DIR}/${report}.json`, + ]; +} + +function turbo(task: string[], report: string, extra: string[]): Invocation { + return { + argv: [ + "pnpm", + "exec", + "turbo", + "run", + ...task, + "--continue=always", + "--", + ...reporter(report), + ...extra, + ], + reports: { turbo: task, report }, + }; +} + +function api(runtime: string[], project: string, report: string, extra: string[]): Invocation { + return { + argv: [ + "pnpm", + "--dir", + "packages/api", + "exec", + ...runtime, + "vitest", + "run", + "--project", + project, + ...reporter(report), + ...extra, + ], + reports: { dir: "packages/api", report }, + }; +} + +function unit(iteration: number, extra: string[]): Invocation[] { + return [ + turbo(["test:unit:run", "--filter=!@supabase/cli-go"], `unit.${iteration}`, [ + "--passWithNoTests", + ...extra, + ]), + ]; +} + +// @supabase/api chains a Node and a Bun Vitest run in one script, and Turbo forwards arguments +// only to the last command, so its two projects run separately with their own reports. +function integration(iteration: number, extra: string[]): Invocation[] { + const args = ["--passWithNoTests", ...extra]; + return [ + turbo(["test:integration:run", "--filter=!@supabase/api"], `integration.${iteration}`, args), + api([], "integration", `integration-node.${iteration}`, args), + api(["bun", "--bun"], "bun-integration", `integration-bun.${iteration}`, args), + ]; +} + +function stack(file: string, report: string, args: string[]): Invocation { + return { + argv: [ + "pnpm", + "--dir", + "packages/stack", + "run", + "test:e2e:run", + `src/${file}.e2e.test.ts`, + ...args, + "--passWithNoTests=false", + ...reporter(report), + ], + reports: { dir: "packages/stack", report }, + }; +} + +/** The invocations of one execution of a job, in order; each runs even when an earlier one fails. */ +export function commandsFor(entry: Entry, iteration: number, extra: string[]): Invocation[] { + switch (entry.suite) { + case "unit": + return unit(iteration, extra); + case "integration": + return integration(iteration, extra); + case "focused": + return [...unit(iteration, extra), ...integration(iteration, extra)]; + case "e2e": + return entry.target === "cli" + ? [ + turbo(["test:e2e:run", "--only", "--filter=supabase"], "e2e.1", [ + `--shard=${entry.shard}/${CLI_E2E_SHARDS}`, + ]), + ] + : [ + turbo(["test:e2e:run", "--only", "--filter=@supabase/cli-e2e"], "e2e.1", [ + `--shard=${entry.shard}/${CLI_E2E_PACKAGE_SHARDS}`, + ]), + ]; + case "stack-e2e": { + // Mirrors test.yml, which gives the confinement scenario of the lifecycle file its own job. + const file = entry.scenario === "idle-parallel" ? "idle-parallel" : "lifecycle"; + const pattern = { + lifecycle: "^(?!.*writes stay confined)", + confinement: "writes stay confined", + "idle-parallel": ".", + }[entry.scenario]; + const scenario = stack( + `whole-stack.${entry.runtime}.${file}`, + `whole-stack.${entry.runtime}.${entry.scenario}.1`, + [`--testNamePattern=${pattern}`, "--sequence.concurrent"], + ); + // test.yml runs these runtime-independent suites once, so one job here does too. + return entry.runtime === "native" && entry.scenario === "lifecycle" + ? [ + scenario, + stack("public", "public.1", []), + stack("namespace/Namespace", "namespace.1", []), + stack("port-reservation", "port-reservation.1", []), + ] + : [scenario]; + } + } +} diff --git a/.github/scripts/flaky-check/report.test.ts b/.github/scripts/flaky-check/report.test.ts new file mode 100644 index 0000000000..4c023f5110 --- /dev/null +++ b/.github/scripts/flaky-check/report.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, test } from "bun:test"; +import type { JsonTestResults } from "vitest/node"; +import { + aggregate, + commentBody, + isClean, + parseVitestJson, + renderMarkdown, + type RunMeta, + type RunResult, +} from "./report.ts"; + +// Real `--reporter=json` output, trimmed to the fields read, of the same three probe files run twice. The second iteration +// fails one test, fails another together with its `afterEach`, and fails a file's `beforeAll`; +// both iterations fail the second of two same-titled tests and a file that throws on import. +const iteration1: JsonTestResults = await Bun.file( + `${import.meta.dir}/fixtures/iteration-1.json`, +).json(); +const iteration2: JsonTestResults = await Bun.file( + `${import.meta.dir}/fixtures/iteration-2.json`, +).json(); +// What Vitest reports for a healthy file: the hook file from the first iteration. +const clean: JsonTestResults = { + ...iteration1, + testResults: iteration1.testResults.filter((file) => file.name.endsWith("hook.unit.test.ts")), +}; +const empty: JsonTestResults = { ...iteration1, testResults: [] }; +const API = "packages__api--unit"; + +function run( + suite: string, + runNumber: number, + reports: Record, + meta: Partial = {}, +): RunResult { + const exitCode = meta.exitCode === undefined ? 0 : meta.exitCode; + return { + meta: { + name: `flaky-${suite}-run${runNumber}`, + suite, + run: runNumber, + executions: 1, + exitCode, + root: "/repo", + expectedReports: Object.keys(reports), + failedInvocations: + exitCode === null || exitCode === 0 ? [] : [{ reports: Object.keys(reports), exitCode }], + ...meta, + }, + reports: Object.entries(reports).map(([name, results]) => ({ + name, + iteration: Number(/\.(\d+)\.json$/.exec(name)?.[1]), + cases: parseVitestJson(results, "/repo"), + })), + }; +} + +function summary(results: RunResult[], expected: string[]) { + const report = aggregate(results, expected); + const rows = (verdicts: typeof report.flaky) => + verdicts.map((v) => [ + v.file.split("/").pop(), + v.name, + `${v.failedIn.length}/${v.executions}`, + v.message, + ]); + return { + flaky: rows(report.flaky), + failing: rows(report.failing), + runProblems: report.runProblems, + }; +} + +describe("aggregate", () => { + test("executions classify tests the same whether they are iterations of one run or separate runs", () => { + const expected = { + flaky: [ + ["hook.unit.test.ts", "(file setup)", "1/2", "failed outside any test (hook or setup)"], + [ + "probe.unit.test.ts", + "sometimes > fails on the second iteration", + "1/2", + "Error: second iteration", + ], + [ + "probe.unit.test.ts", + "two errors > test and afterEach both fail on the second iteration", + "1/2", + "Error: test body", + ], + ], + failing: [ + ["import-error.unit.test.ts", "(file setup)", "2/2", "import failure"], + ["probe.unit.test.ts", "@supabase/api > twin (#2)", "2/2", "Error: second twin"], + ], + runProblems: [], + }; + + const iterations = summary( + [ + run( + "focused", + 1, + { [`${API}.1.json`]: iteration1, [`${API}.2.json`]: iteration2 }, + { executions: 2, exitCode: 1 }, + ), + ], + ["flaky-focused-run1"], + ); + const runs = summary( + [ + run("unit", 1, { [`${API}.1.json`]: iteration1 }, { exitCode: 1 }), + run("unit", 2, { [`${API}.1.json`]: iteration2 }, { exitCode: 1 }), + ], + ["flaky-unit-run1", "flaky-unit-run2"], + ); + + expect(iterations).toEqual(expected); + expect(runs).toEqual(expected); + }); + + test("scenarios sharing a file stay separate, and a failing setup is not an empty run", () => { + const hook = (results: JsonTestResults): JsonTestResults => ({ + ...results, + testResults: results.testResults.filter((file) => file.name.endsWith("hook.unit.test.ts")), + }); + const lifecycle = "packages__stack--whole-stack.docker.lifecycle.1.json"; + const confinement = "packages__stack--whole-stack.docker.confinement.1.json"; + const results = [1, 2].flatMap((n) => [ + run( + "stack-e2e", + n, + { [lifecycle]: hook(iteration2) }, + { name: `lifecycle${n}`, exitCode: 1 }, + ), + run("stack-e2e", n, { [confinement]: hook(iteration1) }, { name: `confinement${n}` }), + ]); + + const report = summary(results, ["lifecycle1", "confinement1", "lifecycle2", "confinement2"]); + + expect(report).toEqual({ + flaky: [], + failing: [ + [ + "hook.unit.test.ts", + "(file setup) [whole-stack.docker.lifecycle]", + "2/2", + "failed outside any test (hook or setup)", + ], + ], + runProblems: [], + }); + }); + + test("a file whose tests were all skipped does not count as a passing setup", () => { + const [hookFile] = clean.testResults; + const failingHook: JsonTestResults = { + ...iteration2, + testResults: iteration2.testResults.filter((file) => file.name.endsWith("hook.unit.test.ts")), + }; + const skippedHook: JsonTestResults = { + ...clean, + testResults: [ + { + ...hookFile!, + assertionResults: hookFile!.assertionResults.map((a) => ({ ...a, status: "skipped" })), + }, + { ...hookFile!, name: "/repo/other.unit.test.ts" }, + ], + }; + + const report = summary( + [ + run("unit", 1, { [`${API}.1.json`]: failingHook }, { exitCode: 1 }), + run("unit", 2, { [`${API}.1.json`]: skippedHook }), + ], + ["flaky-unit-run1", "flaky-unit-run2"], + ); + + expect(report).toEqual({ + flaky: [], + failing: [ + ["hook.unit.test.ts", "(file setup)", "1/1", "failed outside any test (hook or setup)"], + ], + runProblems: [], + }); + }); + + test("reports runs that lost an expected report, crashed, ran nothing in some execution, or never uploaded", () => { + const report = aggregate( + [ + run( + "unit", + 1, + { [`${API}.1.json`]: clean }, + { expectedReports: [`${API}.1.json`, "apps__cli--unit.1.json"] }, + ), + run("unit", 2, { [`${API}.1.json`]: clean }, { exitCode: 1 }), + run("unit", 3, { [`${API}.1.json`]: empty }), + run("unit", 4, {}, { exitCode: null }), + run( + "integration", + 1, + { "apps__cli--integration.1.json": iteration2, [`${API}.1.json`]: clean }, + { + exitCode: 1, + failedInvocations: [ + { reports: ["apps__cli--integration.1.json"], exitCode: 1 }, + { reports: [`${API}.1.json`], exitCode: 1 }, + ], + }, + ), + run("focused", 1, { [`${API}.1.json`]: empty }), + run( + "focused", + 2, + { [`${API}.1.json`]: clean, [`${API}.2.json`]: empty }, + { executions: 2 }, + ), + ], + ["1", "2", "3", "4", "5"] + .map((n) => `flaky-unit-run${n}`) + .concat("flaky-focused-run1", "flaky-focused-run2", "flaky-integration-run1"), + ["focused"], + ); + + expect(report.runProblems).toEqual([ + { name: "flaky-focused-run2", problem: "ran no tests in iteration 2" }, + { + name: "flaky-integration-run1", + problem: `${API}.1.json exited 1 without a failing test (unhandled error or crash)`, + }, + { name: "flaky-unit-run1", problem: "no report for apps__cli--unit.1.json" }, + { + name: "flaky-unit-run2", + problem: `${API}.1.json exited 1 without a failing test (unhandled error or crash)`, + }, + { name: "flaky-unit-run3", problem: "ran no tests; check the filter" }, + { + name: "flaky-unit-run4", + problem: + "test step did not run or finish (setup failure, flaky-check error, timeout, or cancel); see the job log", + }, + { + name: "flaky-unit-run5", + problem: "no results uploaded (the job failed before collecting, such as in setup)", + }, + ]); + expect([report.flaky, report.failing.filter((v) => v.suite !== "integration")]).toEqual([ + [], + [], + ]); + }); +}); + +describe("renderMarkdown", () => { + test("a failing report escapes mentions and locates iterations, and a clean one leads with the marker", () => { + const context = { sha: "0123456789abcdef", runUrl: "https://example.test/run" }; + const failing = renderMarkdown( + aggregate( + [ + run( + "focused", + 1, + { [`${API}.1.json`]: iteration1, [`${API}.2.json`]: iteration2 }, + { executions: 2, exitCode: 1 }, + ), + ], + ["flaky-focused-run1"], + ), + context, + ); + const cleanReport = aggregate( + [run("unit", 1, { [`${API}.1.json`]: clean })], + ["flaky-unit-run1"], + ); + const cleanMarkdown = renderMarkdown(cleanReport, context); + + expect(failing).toContain( + "| focused | sometimes > fails on the second iteration | `packages/api/src/probe.unit.test.ts` | 1/2 | 1.2 | Error: second iteration |", + ); + expect(failing).toContain("| focused | @supabase/api > twin (#2) |"); + expect(commentBody(failing)).toBe(failing); + const truncated = commentBody(failing.repeat(200)); + expect(truncated.length).toBeLessThanOrEqual(65_000); + expect(failing.repeat(200)).toStartWith(truncated.split("\n\n…truncated")[0] + "\n"); + expect(truncated).toEndWith("`flaky-check-report` artifact.\n"); + expect(isClean(cleanReport)).toBe(true); + expect(cleanMarkdown.split("\n").slice(0, 4)).toEqual([ + "", + "## Flaky test check: ✅ no flaky tests", + "", + "Commit `0123456789ab` · unit ×1, 1 test · [workflow run](https://example.test/run)", + ]); + }); +}); diff --git a/.github/scripts/flaky-check/report.ts b/.github/scripts/flaky-check/report.ts new file mode 100644 index 0000000000..cd68d683b7 --- /dev/null +++ b/.github/scripts/flaky-check/report.ts @@ -0,0 +1,368 @@ +import { relative } from "node:path"; +import type { JsonTestResults } from "vitest/node"; + +export const COMMENT_MARKER = ""; +const MAX_ROWS = 50; +// GitHub rejects issue comments longer than 65,536 characters. +const MAX_COMMENT = 65_000; +const MAX_MESSAGE = 200; +const FILE_LEVEL_TITLE = "(file setup)"; +const SKIPPED = new Set(["skipped", "pending", "todo", "disabled"]); + +/** What one matrix job recorded next to its Vitest JSON reports in `meta.json`. */ +export type RunMeta = { + name: string; + suite: string; + run: number; + /** Executions of the suite in this run, each a separate Vitest process. */ + executions: number; + /** `null` when the test step did not run or finish, such as after a setup failure or timeout. */ + exitCode: number | null; + /** Checkout root the reports' absolute file paths are relative to. */ + root: string; + /** Collected report names the run's invocations were expected to write. */ + expectedReports: string[]; + /** Invocations that exited nonzero, with the collected report names each was expected to write. */ + failedInvocations: { reports: string[]; exitCode: number }[]; +}; + +type TestCase = { + file: string; + titles: string[]; + failed: boolean; + skipped: boolean; + /** File-level entries record `beforeAll` and import failures, which no test entry carries. */ + fileLevel: boolean; + message?: string; +}; + +/** One Vitest JSON report; `iteration` tells executions within a run apart. */ +type CollectedReport = { name: string; iteration: number; cases: TestCase[] }; + +export type RunResult = { meta: RunMeta; reports: CollectedReport[] }; + +type Execution = { run: number; iteration: number }; + +type TestVerdict = { + suite: string; + /** Report name without its directory and iteration, such as `whole-stack.docker.lifecycle`. */ + source: string; + file: string; + name: string; + executions: number; + failedIn: Execution[]; + message?: string; +}; + +type RunProblem = { name: string; problem: string }; + +type Report = { + flaky: TestVerdict[]; + failing: TestVerdict[]; + runProblems: RunProblem[]; + suites: { suite: string; runs: number; executions: number; tests: number }[]; +}; + +function firstLine(message: string): string { + return message.split("\n", 1)[0] ?? ""; +} + +/** + * Flattens one Vitest JSON report of a single execution. Each file also gets a file-level entry, + * because a failing `beforeAll` or import only marks the file failed. + */ +export function parseVitestJson(results: JsonTestResults, root: string): TestCase[] { + return results.testResults.flatMap((fileResult) => { + const file = relative(root, fileResult.name); + const tests = fileResult.assertionResults.map((assertion): TestCase => { + const message = assertion.failureMessages?.[0]; + return { + file, + titles: [...assertion.ancestorTitles, assertion.title], + failed: assertion.status === "failed", + skipped: SKIPPED.has(assertion.status), + fileLevel: false, + ...(message === undefined ? {} : { message: firstLine(message) }), + }; + }); + // With failing tests and an empty message, the file status says nothing more about hooks. + const fileFailed = + fileResult.status === "failed" && + (fileResult.message !== "" || !tests.some((test) => test.failed)); + const fileLevel: TestCase = { + file, + titles: [FILE_LEVEL_TITLE], + failed: fileFailed, + // Hooks do not run in a file whose tests were all skipped, so its setup was not exercised. + skipped: !fileFailed && tests.length > 0 && tests.every((test) => test.skipped), + fileLevel: true, + ...(fileFailed + ? { message: firstLine(fileResult.message) || "failed outside any test (hook or setup)" } + : {}), + }; + return [...tests, fileLevel]; + }); +} + +/** `allowEmpty` names suites where running no tests is legitimate, like `focused` with no affected tests. */ +export function aggregate( + results: RunResult[], + expected: string[], + allowEmpty: string[] = [], +): Report { + const verdicts = new Map(); + const runProblems: RunProblem[] = []; + const suites = new Map; executions: number; tests: Set }>(); + + for (const { meta, reports } of results) { + const suite = suites.get(meta.suite) ?? { + runs: new Set(), + executions: meta.executions, + tests: new Set(), + }; + suite.runs.add(meta.run); + suites.set(meta.suite, suite); + + /** Iterations that neither ran a test nor failed, such as a repetition that selected nothing. */ + const emptyIterations: number[] = []; + const iterations = [...new Set(reports.map((report) => report.iteration))].sort( + (a, b) => a - b, + ); + for (const iteration of iterations) { + // Tests sharing a title in one file stay distinct by their order of appearance, and + // scenarios that run one file under different report names stay distinct by report. + const occurrences = new Map(); + let ranTests = 0; + let iterationFailed = false; + const cases = reports + .filter((report) => report.iteration === iteration) + .flatMap((report) => + report.cases.map((testCase) => ({ + source: report.name.replace(/\.\d+\.json$/, ""), + testCase, + })), + ); + for (const { source, testCase } of cases) { + const id = `${source}\0${testCase.file}\0${testCase.titles.join("\0")}`; + const occurrence = (occurrences.get(id) ?? 0) + 1; + occurrences.set(id, occurrence); + if (testCase.skipped) { + continue; + } + const key = `${meta.suite}\0${id}\0${occurrence}`; + if (!testCase.fileLevel) { + ranTests += 1; + suite.tests.add(key); + } + const title = testCase.titles.join(" > "); + const verdict = verdicts.get(key) ?? { + suite: meta.suite, + source: source.replace(/^.*--/, ""), + file: testCase.file, + name: occurrence === 1 ? title : `${title} (#${occurrence})`, + executions: 0, + failedIn: [], + }; + verdict.executions += 1; + if (testCase.failed) { + iterationFailed = true; + verdict.failedIn.push({ run: meta.run, iteration }); + verdict.message ??= testCase.message; + } + verdicts.set(key, verdict); + } + if (ranTests === 0 && !iterationFailed) { + emptyIterations.push(iteration); + } + } + + const collected = new Set(reports.map((report) => report.name)); + const withFailure = new Set( + reports.filter((report) => report.cases.some((c) => c.failed)).map((report) => report.name), + ); + // A failing test elsewhere in the job does not explain another invocation's nonzero exit. + const crashed = meta.failedInvocations.filter( + (invocation) => !invocation.reports.some((name) => withFailure.has(name)), + ); + const missing = meta.expectedReports.filter((name) => !collected.has(name)).sort(); + if (meta.exitCode === null) { + runProblems.push({ + name: meta.name, + problem: + "test step did not run or finish (setup failure, flaky-check error, timeout, or cancel); see the job log", + }); + } else if (reports.length === 0) { + runProblems.push({ + name: meta.name, + problem: `exited ${meta.exitCode} without writing a report`, + }); + } else if (missing.length > 0) { + runProblems.push({ name: meta.name, problem: `no report for ${missing.join(", ")}` }); + } else if (crashed.length > 0) { + runProblems.push({ + name: meta.name, + problem: crashed + .map( + (invocation) => + `${invocation.reports.join(", ") || "an invocation"} exited ${invocation.exitCode} without a failing test (unhandled error or crash)`, + ) + .join("; "), + }); + } else if (emptyIterations.length === iterations.length) { + if (!allowEmpty.includes(meta.suite)) { + runProblems.push({ name: meta.name, problem: "ran no tests; check the filter" }); + } + } else if (emptyIterations.length > 0) { + runProblems.push({ + name: meta.name, + problem: `ran no tests in iteration ${emptyIterations.join(", ")}`, + }); + } + } + + const seen = new Set(results.map(({ meta }) => meta.name)); + for (const name of expected) { + if (!seen.has(name)) { + runProblems.push({ + name, + problem: "no results uploaded (the job failed before collecting, such as in setup)", + }); + } + } + + // A test that several scenarios run under one name gets its source, so rows stay distinguishable. + const sources = new Map>(); + for (const v of verdicts.values()) { + const label = `${v.suite}\0${v.file}\0${v.name}`; + sources.set(label, (sources.get(label) ?? new Set()).add(v.source)); + } + for (const v of verdicts.values()) { + if ((sources.get(`${v.suite}\0${v.file}\0${v.name}`)?.size ?? 0) > 1) { + v.name = `${v.name} [${v.source}]`; + } + } + + const failed = [...verdicts.values()] + .filter((v) => v.failedIn.length > 0) + .sort( + (a, b) => + b.failedIn.length / b.executions - a.failedIn.length / a.executions || + a.file.localeCompare(b.file), + ); + const failsEveryExecution = (v: TestVerdict) => v.failedIn.length === v.executions; + return { + flaky: failed.filter((v) => !failsEveryExecution(v)), + failing: failed.filter(failsEveryExecution), + runProblems: runProblems.sort((a, b) => a.name.localeCompare(b.name)), + suites: [...suites.entries()] + .map(([suite, { runs, executions, tests }]) => ({ + suite, + runs: runs.size, + executions, + tests: tests.size, + })) + .sort((a, b) => a.suite.localeCompare(b.suite)), + }; +} + +export function isClean(report: Report): boolean { + return ( + report.flaky.length === 0 && report.failing.length === 0 && report.runProblems.length === 0 + ); +} + +function cell(value: string): string { + const flat = value.replace(/\s+/g, " ").trim(); + const short = flat.length > MAX_MESSAGE ? `${flat.slice(0, MAX_MESSAGE)}…` : flat; + // The empty comment keeps `@scope/name` in test titles from mentioning GitHub users or teams. + return short.replaceAll("|", "\\|").replaceAll("<", "<").replaceAll("@", "@"); +} + +function where(executions: Execution[], repeated: boolean): string { + return [...executions] + .sort((a, b) => a.run - b.run || a.iteration - b.iteration) + .map((e) => (repeated ? `${e.run}.${e.iteration}` : `${e.run}`)) + .join(", "); +} + +function testTable(title: string, verdicts: TestVerdict[], repeated: Set): string[] { + if (verdicts.length === 0) { + return []; + } + const rows = verdicts + .slice(0, MAX_ROWS) + .map( + (v) => + `| ${v.suite} | ${cell(v.name)} | \`${v.file}\` | ${v.failedIn.length}/${v.executions} | ${where(v.failedIn, repeated.has(v.suite))} | ${cell(v.message ?? "")} |`, + ); + const lines = [ + `### ${title} (${verdicts.length})`, + "", + "| Suite | Test | File | Failed | Where | First failure |", + "| --- | --- | --- | --- | --- | --- |", + ...rows, + ]; + if (verdicts.length > MAX_ROWS) { + lines.push("", `…and ${verdicts.length - MAX_ROWS} more in \`report.json\`.`); + } + return [...lines, ""]; +} + +export function renderMarkdown( + report: Report, + { sha, runUrl }: { sha: string; runUrl: string }, +): string { + const counts = [ + report.flaky.length > 0 ? `${report.flaky.length} flaky` : "", + report.failing.length > 0 ? `${report.failing.length} failed every time` : "", + report.runProblems.length > 0 ? `${report.runProblems.length} runs without results` : "", + ].filter(Boolean); + const heading = isClean(report) ? "✅ no flaky tests" : `⚠️ ${counts.join(", ")}`; + const suites = report.suites + .map((s) => + s.tests === 0 + ? `${s.suite} ×${s.runs} (no tests ran)` + : `${s.suite} ×${s.runs}${s.executions > 1 ? ` (${s.executions} executions each)` : ""}, ${s.tests} test${s.tests === 1 ? "" : "s"}`, + ) + .join(" · "); + const repeated = new Set(report.suites.filter((s) => s.executions > 1).map((s) => s.suite)); + + const lines = [ + COMMENT_MARKER, + `## Flaky test check: ${heading}`, + "", + `Commit \`${sha.slice(0, 12)}\` · ${suites || "no runs"} · [workflow run](${runUrl})`, + "", + ...testTable("Flaky tests", report.flaky, repeated), + ...testTable("Failed every time", report.failing, repeated), + ]; + if (report.runProblems.length > 0) { + lines.push( + `### Runs without results (${report.runProblems.length})`, + "", + "| Run | Problem |", + "| --- | --- |", + ...report.runProblems.slice(0, MAX_ROWS).map((p) => `| \`${p.name}\` | ${cell(p.problem)} |`), + ...(report.runProblems.length > MAX_ROWS + ? ["", `…and ${report.runProblems.length - MAX_ROWS} more in \`report.json\`.`] + : []), + "", + ); + } + lines.push( + "Each execution is a separate Vitest process; `Where` lists failing runs, as `run.iteration` for suites executed more than once per run. The full data is in the `flaky-check-report` artifact.", + ); + return `${lines.join("\n")}\n`; +} + +/** The report cut to fit a PR comment; the job summary and artifact keep the full version. */ +export function commentBody(markdown: string): string { + const note = + "\n\n…truncated; the full report is in the job summary and the `flaky-check-report` artifact.\n"; + if (markdown.length <= MAX_COMMENT) { + return markdown; + } + // Cutting at a line end keeps table rows, mention escapes, and surrogate pairs whole. + const head = markdown.slice(0, MAX_COMMENT - note.length); + return head.slice(0, head.lastIndexOf("\n")) + note; +} diff --git a/.github/workflows/flaky-check.yml b/.github/workflows/flaky-check.yml new file mode 100644 index 0000000000..73a547d9a1 --- /dev/null +++ b/.github/workflows/flaky-check.yml @@ -0,0 +1,244 @@ +name: Flaky check + +# Runs one commit on many fresh runners and reports tests that both passed and failed. The logic +# lives in .github/scripts/flaky-check; triggers and usage are in .github/MAINTAINERS.md. +on: + schedule: + - cron: "17 3 * * *" + - cron: "47 3 * * 0" + pull_request: + types: + - labeled + workflow_dispatch: + inputs: + ref: + description: Branch, tag, or SHA to test (defaults to the branch this runs from) + type: string + default: "" + suites: + description: Comma-separated subset of unit,integration,focused,e2e,stack-e2e + type: string + default: unit,integration + runs: + description: Parallel runs per suite (1-30) + type: number + default: 10 + repeats: + description: Extra executions per focused run, each in a separate Vitest process + type: number + default: 4 + filter: + description: >- + Space-separated vitest file filters for unit, integration, and focused. Focused + repeats these instead of the tests affected by changes since develop when set. + type: string + default: "" + +permissions: + contents: read + +# Other label events still start a run; a unique group keeps them from cancelling a real one. +concurrency: + group: >- + flaky-check-${{ github.event.pull_request.number || github.ref }}-${{ github.event.schedule || github.event_name }}${{ + (github.event.action == 'labeled' && github.event.label.name != 'run-flaky-check' + && format('-noop-{0}', github.run_id)) + || '' + }}${{ github.event_name == 'workflow_dispatch' && format('-{0}', github.run_id) || '' }} + cancel-in-progress: true + +env: + # The CLI comes from this workflow's revision, so runs against older refs can use it. + FLAKY_CLI: .flaky-tooling/.github/scripts/flaky-check/cli.ts + +jobs: + plan: + if: | + (github.event_name == 'schedule' && github.repository == 'supabase/cli') || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && github.event.label.name == 'run-flaky-check') + name: Plan runs + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + outputs: + sha: ${{ steps.plan.outputs.sha }} + base: ${{ steps.plan.outputs.base }} + filter: ${{ steps.plan.outputs.filter }} + matrix: ${{ steps.plan.outputs.matrix }} + steps: + - name: Checkout flaky-check tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: .flaky-tooling + sparse-checkout: .github/scripts/flaky-check + persist-credentials: false + + - name: Install Bun + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + version: 2026.9.0 + install: true + install_args: bun + working_directory: .flaky-tooling + + - name: Plan runs + id: plan + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + SCHEDULE: ${{ github.event.schedule }} + EVENT_SHA: ${{ github.sha }} + PR_BASE_REF: ${{ github.base_ref }} + INPUT_REF: ${{ inputs.ref }} + INPUT_SUITES: ${{ inputs.suites }} + INPUT_RUNS: ${{ inputs.runs }} + INPUT_REPEATS: ${{ inputs.repeats }} + INPUT_FILTER: ${{ inputs.filter }} + run: bun "$FLAKY_CLI" plan + + # Removing the label lets a maintainer re-add it to start another run. Fork PRs get a + # read-only token, so the label stays there and must be removed by hand. + - name: Remove trigger label + if: >- + always() && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + gh api --method DELETE "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/labels/run-flaky-check" >/dev/null \ + || echo "::warning::Unable to remove the run-flaky-check label." + + run: + needs: plan + name: ${{ matrix.name }} + runs-on: ${{ matrix.runner }} + timeout-minutes: ${{ matrix.timeout }} + env: + SUPABASE_STACK_TEST_ENGINE: ${{ matrix.runtime == 'podman' && 'podman' || 'docker' }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.plan.outputs.sha }} + fetch-depth: ${{ matrix.fetchDepth }} + persist-credentials: false + + - name: Checkout flaky-check tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: .flaky-tooling + sparse-checkout: .github/scripts/flaky-check + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + with: + dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + sticky-disks: "true" + go-cache: ${{ matrix.suite == 'e2e' }} + docker-images: ${{ matrix.suite != 'unit' && (matrix.suite != 'stack-e2e' || matrix.runtime == 'docker' || matrix.scenario == 'lifecycle' && matrix.runtime == 'native') }} + native-artifacts: ${{ matrix.runtime == 'native' }} + + - name: Setup Podman + if: matrix.runtime == 'podman' + uses: ./.github/actions/setup-podman + + - name: Build CLI + if: matrix.suite == 'e2e' + run: pnpm exec turbo run supabase#build + + - name: Run tests + id: run + env: + MATRIX: ${{ toJSON(matrix) }} + BASE_REF: ${{ needs.plan.outputs.base }} + FILTER: ${{ needs.plan.outputs.filter }} + run: bun "$FLAKY_CLI" run + + - name: Collect results + id: collect + if: always() + env: + MATRIX: ${{ toJSON(matrix) }} + EXIT_CODE: ${{ steps.run.outputs.exit-code }} + EXPECTED_REPORTS: ${{ steps.run.outputs.expected-reports }} + FAILED_INVOCATIONS: ${{ steps.run.outputs.failed-invocations }} + run: bun "$FLAKY_CLI" collect + + - name: Upload results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.name }} + path: ${{ steps.collect.outputs.dir }} + retention-days: 14 + if-no-files-found: error + overwrite: true + + # Releases /var/lib/docker so the image disk unmounts cleanly. + - name: Stop Docker + if: always() && matrix.suite != 'unit' && (matrix.suite != 'stack-e2e' || matrix.runtime == 'docker' || matrix.scenario == 'lifecycle' && matrix.runtime == 'native') + run: sudo systemctl stop docker.socket docker.service + + report: + needs: [plan, run] + if: ${{ !cancelled() && needs.plan.result == 'success' }} + name: Report flaky tests + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout flaky-check tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: .flaky-tooling + sparse-checkout: .github/scripts/flaky-check + persist-credentials: false + + - name: Install Bun + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + version: 2026.9.0 + install: true + install_args: bun + working_directory: .flaky-tooling + + - name: Download results + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: flaky-*-run* + path: results + + - name: Build report + env: + MATRIX: ${{ needs.plan.outputs.matrix }} + SHA: ${{ needs.plan.outputs.sha }} + FILTER: ${{ needs.plan.outputs.filter }} + run: bun "$FLAKY_CLI" report results flaky-report + + - name: Upload report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: flaky-check-report + path: flaky-report + retention-days: 30 + overwrite: true + + - name: Comment on PR + if: >- + always() && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: bun "$FLAKY_CLI" comment flaky-report/report.md diff --git a/.github/workflows/github-scripts-ci.yml b/.github/workflows/github-scripts-ci.yml index c37e557139..37a5c6cf43 100644 --- a/.github/workflows/github-scripts-ci.yml +++ b/.github/workflows/github-scripts-ci.yml @@ -21,6 +21,7 @@ on: - ".github/sync-branches/**" - "apps/cli/scripts/analyze-commits-title.js" - ".github/workflows/github-scripts-ci.yml" + - ".github/workflows/flaky-check.yml" - ".github/workflows/mirror-slim-image.yml" permissions: {} diff --git a/.gitignore b/.gitignore index ff9c4a0605..8873eb8d7c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ coverage/ .agents/.repos/effect-v3 .repos/slim-services/ .worktrees/ +# Vitest JSON reports written by .github/scripts/flaky-check. +.flaky-results/ .supabase/ erl_crash.dump # Stray `supabase` project dir created by running the CLI at the repo root diff --git a/knip.jsonc b/knip.jsonc index b472a39fbf..c52ce79f97 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -9,6 +9,11 @@ "ignoreDependencies": ["verdaccio"], "ignoreWorkspaces": ["apps/cli-go"], "workspaces": { + ".": { + // flaky-check.yml runs it from a second checkout of the workflow revision, through a + // path knip cannot trace. + "entry": [".github/scripts/flaky-check/cli.ts"], + }, "apps/cli": { "entry": [ // Bundled by `bun build` in the build:shim script; knip's bun resolver derives no From a09ec14ad0a4bd2416332a88562099816f9e8205 Mon Sep 17 00:00:00 2001 From: Andrew Valleteau Date: Thu, 15 Oct 2026 12:42:19 +0000 Subject: [PATCH 23/26] fix(stack): pin analytics to Logflare 1.50.15 (#7182) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Local Studio logs pages fail with "Backend error" on the default Postgres analytics backend (#7155). Logflare 1.52.0 and later cast endpoint query parameters such as `@project` to `jsonb` when translating to Postgres, so every logs query fails with `invalid input syntax for type json` (Logflare/logflare#4158). - Pins `analytics` back to Logflare 1.50.15 (slim `v1.50.15-r1`), the last release before the regression, in the stack catalog and both service Dockerfiles. - Adds an `UPGRADE_HOLDS` entry for `analytics` to the slim release planner so the bump bot can't upgrade it again. Hotfix revisions of 1.50.15 still go through. Remove the entry once Logflare ships a fix. The per-minute log chart still fails on the Postgres backend because `IFNULL` is never translated (Logflare/logflare#4159); that has been the case in every Logflare version. A local database first created by Logflare 1.53.x (CLI 2.121.0) keeps its Oban tables only in `_analytics`, which 1.50.15 doesn't read. On those databases the analytics alerting and event-touch workers restart in a loop and log `relation "public.oban_jobs" does not exist`. Log ingestion and queries are unaffected, and `supabase stop --no-backup` clears it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5.5 --- .../scripts/sync-artifacts-catalog.test.ts | 36 +++++++++++++++++++ .github/scripts/sync-artifacts-catalog.ts | 23 +++++++++++- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- apps/cli/src/shared/services/Dockerfile | 2 +- docs/adr/0026-slim-artifact-mirrors.md | 4 +++ packages/stack/src/Artifacts.ts | 20 +++++------ 6 files changed, 74 insertions(+), 13 deletions(-) diff --git a/.github/scripts/sync-artifacts-catalog.test.ts b/.github/scripts/sync-artifacts-catalog.test.ts index d969bcffea..648b4d65a4 100644 --- a/.github/scripts/sync-artifacts-catalog.test.ts +++ b/.github/scripts/sync-artifacts-catalog.test.ts @@ -340,6 +340,42 @@ describe("planSlimUpdates", () => { ]); }); + test("a held service skips the upgrade with a warning but still takes the pinned upstream's hotfix", () => { + const analyticsFixture = `const workloadCatalog = { + analytics: definition( + "analytics", + { + upstreamVersion: "v1.50.15", + revision: 1, + image: "ghcr.io/supabase/cli/analytics:v1.50.15-r1@sha256:4444444444444444444444444444444444444444444444444444444444444", + natives: {}, + }, + "bin/analytics", + ), +}; +`; + + const { updates, warnings } = planSlimUpdates(analyticsFixture, "analytics", [ + "analytics-v1.50.15-r2", + "analytics-v1.53.1-r0", + ]); + + expect(updates).toEqual([ + { + kind: "hotfix", + line: undefined, + branch: "slim-hotfix/analytics", + title: "chore(stack): pin analytics v1.50.15-r2", + fromRelease: "v1.50.15-r1", + toUpstream: "v1.50.15", + toRelease: "v1.50.15-r2", + }, + ]); + expect(warnings).toEqual([ + "::warning ::analytics upgrade to v1.53.1-r0 held at v1.50.15: Logflare 1.52+ breaks Postgres-backend endpoint queries (https://github.com/Logflare/logflare/issues/4158).", + ]); + }); + test("an older committed upstream is a backlog republish: it plans nothing", () => { const { updates, warnings } = planSlimUpdates(plannerFixture, "postgrest", [ "postgrest-v16.1-r0", diff --git a/.github/scripts/sync-artifacts-catalog.ts b/.github/scripts/sync-artifacts-catalog.ts index 5cea7a27e3..bce8748fb9 100644 --- a/.github/scripts/sync-artifacts-catalog.ts +++ b/.github/scripts/sync-artifacts-catalog.ts @@ -812,6 +812,16 @@ export interface PlanSlimUpdatesResult { readonly warnings: ReadonlyArray; } +/** + * Services whose pinned upstream must not be upgraded automatically, with the reason surfaced in + * the planner warning. Hotfix revisions of the pinned upstream still apply; delete the entry to + * resume upgrades. + */ +const UPGRADE_HOLDS: Readonly> = { + analytics: + "Logflare 1.52+ breaks Postgres-backend endpoint queries (https://github.com/Logflare/logflare/issues/4158)", +}; + /** Groups every candidate release tag under one bucket, for a service with no additional pins. */ const SINGLE_LINE_KEY = "*"; @@ -931,7 +941,18 @@ export function planSlimUpdates( (upstream) => compareOnLine(upstream, pinned.upstream) !== undefined, ); - if (best !== undefined && compareOnLine(best.upstream, pinned.upstream) === 1) { + const isUpgrade = best !== undefined && compareOnLine(best.upstream, pinned.upstream) === 1; + const hold = UPGRADE_HOLDS[service]; + if (isUpgrade && hold !== undefined) { + warnings.push( + workflowCommand( + "warning", + `${service} upgrade to ${best.upstream}-r${best.revision} held at ${pinned.upstream}: ${hold}.`, + ), + ); + } + + if (isUpgrade && hold === undefined) { updates.push( buildUpdate("upgrade", service, line, hasLines, pinned, best.upstream, best.revision), ); diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index a0250c30b2..4e1bbcca8b 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -37,7 +37,7 @@ FROM supabase/realtime:v2.144.0 AS realtime FROM supabase/storage-api:v1.83.1 AS storage -FROM supabase/logflare:1.53.1 AS logflare +FROM supabase/logflare:1.50.15 AS logflare # One-shot job images (not started as long-running containers); differ's alias # is resolved via dockerfileServiceImage("differ") in commands/db/diff/pgadmin-diff.ts. FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index a0250c30b2..4e1bbcca8b 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -37,7 +37,7 @@ FROM supabase/realtime:v2.144.0 AS realtime FROM supabase/storage-api:v1.83.1 AS storage -FROM supabase/logflare:1.53.1 AS logflare +FROM supabase/logflare:1.50.15 AS logflare # One-shot job images (not started as long-running containers); differ's alias # is resolved via dockerfileServiceImage("differ") in commands/db/diff/pgadmin-diff.ts. FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ diff --git a/docs/adr/0026-slim-artifact-mirrors.md b/docs/adr/0026-slim-artifact-mirrors.md index 2e9aab7c74..bed767bf6b 100644 --- a/docs/adr/0026-slim-artifact-mirrors.md +++ b/docs/adr/0026-slim-artifact-mirrors.md @@ -139,6 +139,10 @@ pins it. A hotfix PR opened before the upgrade appeared is left as is. Merge it upgrade and the upgrade PR conflicts until the next run for that service rewrites it; merge the upgrade first and the hotfix PR is superseded and must be closed by hand. +A service listed in the planner's `UPGRADE_HOLDS` never gets an upgrade: the planner warns with +the hold's reason instead and still plans hotfixes for the pinned upstream. Removing the entry +resumes upgrades; the manual `--release` invocation is unaffected. + A service with a single pin and no engine variants has a single line, which accepts any comparable newer upstream: a Studio year rollover or a postgrest major bump moves that line forward. Only postgres, which has additional pins and engine variants, assigns each release tag to a line, even diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index f289949f2e..083b7f2708 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -418,23 +418,23 @@ const definitions: Readonly> = { analytics: definition( "analytics", { - upstreamVersion: "v1.53.1", - revision: 0, + upstreamVersion: "v1.50.15", + revision: 1, image: - "ghcr.io/supabase/cli/analytics:v1.53.1-r0@sha256:8052bc661373ae604c0213f14f23b6ff8543896b92f7a78f01f2079f27fae9de", - upstreamImage: "supabase/logflare:1.53.1", + "ghcr.io/supabase/cli/analytics:v1.50.15-r1@sha256:2b03ec3120effe5d33c1e2ec4570a932fb60ce6a6851b4920a22388b4e8356c1", + upstreamImage: "supabase/logflare:1.50.15", natives: { "darwin-arm64": { - archive: "ef10709e22367a6775946b6095f10898d2e357478ec4eadfdd2177b31b23871a", - manifest: "0e0821744e82e09a689d43daf78d2eeb6901ff686841c2fa215696901c955850", + archive: "abfc9ba64e354129e8f8b0f38b43747a5dab48785fd3593d11e52cd6e0ff37bf", + manifest: "60647643f5521089978dc20c6966b86ed939b9751b187cbb70e0de3fbd2479ac", }, "linux-amd64": { - archive: "f13fa6093a1d58cf9a1672a6624f9563af728262d4a01b8a8f971effea7bd5e3", - manifest: "bb5fc95810a3bcecc6605716c8501fa3ec54c5b9ad1454bc26fdb3ad142ad1bc", + archive: "7652456c1a1d73ac6fe10853bd8a38e79139b43fe796caec5725a3d0f225a3d8", + manifest: "6c465af69e26ce8a9c15988d5dcbbae069e9a92b697962bfa86c4f3e601b8409", }, "linux-arm64": { - archive: "9559cef18cc1f63b8c7284285d8402187863d4fcfe2cdc96dc65407fa522179b", - manifest: "2ee730a2b333b52f1f22e7493a05d0c9f11d533a81929243aa265348d76f415e", + archive: "a75625d7c903d70e749b5ee776e2c5c98d51ee60ff92b28bb6684121fe0f3888", + manifest: "ca2569304cf24e6abd4242b55dd0d20338f40532fbe6377a9accdc5d47ea47ab", }, }, }, From 1c2ac9f974810757e4d8ba876ddd0861dd9114a8 Mon Sep 17 00:00:00 2001 From: "supabase-cli-releaser[bot]" <246109035+supabase-cli-releaser[bot]@users.noreply.github.com> Date: Thu, 15 Oct 2026 12:52:28 +0000 Subject: [PATCH 24/26] chore(stack): bump storage to v1.83.3-r0 (#7187) This bumps storage from v1.83.1-r0 to v1.83.3-r0. https://github.com/supabase/slim-services/releases/tag/storage-v1.83.3-r0 Planned after supabase/slim-services published v1.83.3-r0. This branch is rewritten from develop whenever a newer relevant release arrives. Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- apps/cli-go/pkg/config/templates/Dockerfile | 2 +- apps/cli/src/shared/services/Dockerfile | 2 +- packages/stack/src/Artifacts.ts | 18 +++++++++--------- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/apps/cli-go/pkg/config/templates/Dockerfile b/apps/cli-go/pkg/config/templates/Dockerfile index 4e1bbcca8b..730b3b8c84 100644 --- a/apps/cli-go/pkg/config/templates/Dockerfile +++ b/apps/cli-go/pkg/config/templates/Dockerfile @@ -35,7 +35,7 @@ FROM supabase/gotrue:v2.197.0 AS gotrue FROM supabase/realtime:v2.144.0 AS realtime -FROM supabase/storage-api:v1.83.1 AS storage +FROM supabase/storage-api:v1.83.3 AS storage FROM supabase/logflare:1.50.15 AS logflare # One-shot job images (not started as long-running containers); differ's alias diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index 4e1bbcca8b..730b3b8c84 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -35,7 +35,7 @@ FROM supabase/gotrue:v2.197.0 AS gotrue FROM supabase/realtime:v2.144.0 AS realtime -FROM supabase/storage-api:v1.83.1 AS storage +FROM supabase/storage-api:v1.83.3 AS storage FROM supabase/logflare:1.50.15 AS logflare # One-shot job images (not started as long-running containers); differ's alias diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 083b7f2708..fe32209a1c 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -267,23 +267,23 @@ const definitions: Readonly> = { storage: definition( "storage", { - upstreamVersion: "v1.83.1", + upstreamVersion: "v1.83.3", revision: 0, image: - "ghcr.io/supabase/cli/storage:v1.83.1-r0@sha256:16677fbc579cda2d16aee2582adcbdef62e6d21ad0d9bcbacaceacca0e8e6808", - upstreamImage: "supabase/storage-api:v1.83.1", + "ghcr.io/supabase/cli/storage:v1.83.3-r0@sha256:ac03b8058499a192dc447cf7fc6db020f25230d12b282a49a6ba6367025354d0", + upstreamImage: "supabase/storage-api:v1.83.3", natives: { "darwin-arm64": { - archive: "ef3dc7153f2ab5e6e5bc8b99a0dba35a5c31881464efbb7b46ae60a17453c4c9", - manifest: "2b288c2654c6cf225b0a682ad7cbd66c004ac970cd74a201b9c5520d61ea0a7c", + archive: "8208925fef22516668f700fb1fc80478fe2f80fa78452d65cda8862d7f277863", + manifest: "6349e482d794fb4d2aabdb347c4d6e6f62b920eef05e3da66e1b0f9826b196a4", }, "linux-amd64": { - archive: "2805e5dbc74d42aa90ef6afca3002a9c73e47cf3dec0ff4a7faa939782f87b2e", - manifest: "36f4443178e728c74a40d958a33b9256e11bf088b31e76de758bb7547ba1b293", + archive: "eda8dc7b0da3ac7b41c52dc87daec5a1868bd9529c156b947c9a53080bd68c03", + manifest: "754ad2082ffac3f420094c20730c7bb58f4df25382312b2471d45b0af3368f4b", }, "linux-arm64": { - archive: "03fc2d224f2abb24ff42d6a6ccd7c5da479f79ccd1d3f5707fdf766d09bc0c10", - manifest: "703cd82ab5f5a7b74759e43b9fa316b160a779ed73904bfadfad8b6dc8979ec5", + archive: "8520036ba99639e6014a5e222fc858aaceae1aff575db392711d5cd382a227da", + manifest: "dcfc813c207bd501d4d2b87aa62ae8ce77ec8f3e676989ce49cb5eeee656ba78", }, }, }, From 5bc6f26cd1af1df8da7a30e0bfc9b480757f4678 Mon Sep 17 00:00:00 2001 From: Prashansa Kulshrestha Date: Thu, 15 Oct 2026 13:01:00 +0000 Subject: [PATCH 25/26] chore(cli): require every command to declare its permissions (#7185) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the command permission mapping: every command leaf now declares a permission status, so the pending list is gone. The six commands that still hand off to the bundled Go binary (`db branch create|delete|list|switch`, `db remote changes`, `gen keys`) declare `unmapped` with reason `go-delegated`. The completeness test now requires a declaration on every leaf, and a second check fails if a path on the go-delegation allowlist stops being a real leaf declared `unmapped`. The pending list and the registry test that depended on it are removed; the `Undeclared` lookup result stays for command groups. Findings: `unmapped` records how these commands are implemented, not what they need. Reading the Go source, the four `db branch` leaves make no Management API calls, `gen keys` makes one (`V1GetPostgrestServiceConfig`), and `db remote changes` runs the same diff as `db diff` against the linked database. All six could be mapped later, which would let the `unmapped` status and its allowlist go. Untested conditions: the six `unmapped` commands make no claim, so nothing checks them. No blocks without a real-layer test, no best-effort label turned required, no `OPERATIONS_WITHOUT_FGA` entries. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Sonnet 5.5 --- .../command-permissions.unit.test.ts | 46 +++++++++---------- .../db/branch/create/create.command.ts | 2 + .../db/branch/delete/delete.command.ts | 2 + .../commands/db/branch/list/list.command.ts | 2 + .../db/branch/switch/switch.command.ts | 2 + .../db/remote/changes/changes.command.ts | 2 + .../cli/src/commands/gen/keys/keys.command.ts | 2 + .../helpers/permission-pending-commands.ts | 10 ---- .../helpers/permission-registry.unit.test.ts | 12 +---- 9 files changed, 35 insertions(+), 45 deletions(-) delete mode 100644 apps/cli/tests/helpers/permission-pending-commands.ts diff --git a/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts b/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts index c06176921d..1f65b32b40 100644 --- a/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts +++ b/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts @@ -9,7 +9,6 @@ import { OPERATIONS } from "../../../tests/helpers/operation-table.ts"; import { readPermissions } from "./command-permissions.annotation.ts"; import { GLOBAL_NO_API_EFFECT_FLAGS } from "../../../tests/helpers/permission-global-flags.ts"; import type { CommandPermissions, EntryCondition, OperationEntry } from "./model.ts"; -import { PENDING_COMMANDS } from "../../../tests/helpers/permission-pending-commands.ts"; import { DB_TARGET_FLAGS } from "../../../tests/helpers/permission-registry.ts"; import type { PermissionVariant } from "../../../tests/helpers/permission-registry.ts"; @@ -68,39 +67,17 @@ function declarationOf(node: TreeNode): CommandPermissions | undefined { describe("command permission mapping completeness", () => { const trees = VARIANTS.map(({ name, variant }) => ({ name, tree: commandTreeFor(variant) })); - const everyLeafPath = new Set( - trees.flatMap(({ tree }) => [...tree].filter(([, node]) => node.isLeaf).map(([path]) => path)), - ); const everyRealFlag = new Set( trees.flatMap(({ tree }) => [...tree.values()].flatMap((node) => [...node.flags])), ); - const pendingPaths = new Set(PENDING_COMMANDS); - - it("lists every pending path once, and only paths that are real leaves", () => { - expect(pendingPaths.size, "PENDING_COMMANDS has a duplicate entry").toBe( - PENDING_COMMANDS.length, - ); - for (const path of pendingPaths) { - expect( - everyLeafPath.has(path), - `pending path "${path}" is not a leaf in any command tree`, - ).toBe(true); - } - }); for (const { name, tree } of trees) { describe(name, () => { const leaves = [...tree].filter(([, node]) => node.isLeaf); - it("declares or pends every leaf, never both", () => { + it("declares permissions on every leaf", () => { for (const [path, node] of leaves) { - const isDeclared = declarationOf(node) !== undefined; - const isPending = pendingPaths.has(path); - expect(isDeclared || isPending, `"${path}" is neither declared nor pending`).toBe(true); - expect( - isDeclared && isPending, - `"${path}" is both declared and pending — remove it from PENDING_COMMANDS`, - ).toBe(false); + expect(declarationOf(node), `"${path}" declares no permissions`).toBeDefined(); } }); @@ -237,6 +214,25 @@ describe("command permission mapping completeness", () => { }); } + it("keeps every go-delegation allowlist path a real leaf that is declared unmapped", () => { + for (const path of GO_DELEGATED_PATHS) { + const declared = trees + .flatMap(({ tree }) => [tree.get(path)]) + .filter((node) => node?.isLeaf === true) + .map((node) => (node === undefined ? undefined : declarationOf(node))); + expect( + declared.length, + `allowlist path "${path}" is not a leaf in any command tree`, + ).toBeGreaterThan(0); + for (const permissions of declared) { + expect( + permissions?.status, + `allowlist path "${path}" isn't declared unmapped — remove it from the allowlist if it was mapped`, + ).toBe("unmapped"); + } + } + }); + it("acknowledges only operations that really lack x-fga-permissions in the bundled spec", () => { for (const operationId of OPERATIONS_WITHOUT_FGA.keys()) { const operation = OPERATIONS.get(operationId); diff --git a/apps/cli/src/commands/db/branch/create/create.command.ts b/apps/cli/src/commands/db/branch/create/create.command.ts index f9836dfe91..893c8eea61 100644 --- a/apps/cli/src/commands/db/branch/create/create.command.ts +++ b/apps/cli/src/commands/db/branch/create/create.command.ts @@ -1,6 +1,7 @@ import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { dbBranchCreate } from "./create.handler.ts"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = { branchName: Argument.String("branch name").pipe( @@ -13,5 +14,6 @@ export type DbBranchCreateFlags = CliCommand.Command.Config.Infer export const dbBranchCreateCommand = Command.make("create", config).pipe( Command.withDescription("Create a branch."), Command.withShortDescription("Create a branch"), + withPermissions({ status: "unmapped", reason: "go-delegated" }), Command.withHandler((flags) => dbBranchCreate(flags)), ); diff --git a/apps/cli/src/commands/db/branch/delete/delete.command.ts b/apps/cli/src/commands/db/branch/delete/delete.command.ts index c54de6514a..7a4827dea0 100644 --- a/apps/cli/src/commands/db/branch/delete/delete.command.ts +++ b/apps/cli/src/commands/db/branch/delete/delete.command.ts @@ -1,6 +1,7 @@ import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { dbBranchDelete } from "./delete.handler.ts"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = { branchName: Argument.String("branch name").pipe( @@ -13,5 +14,6 @@ export type DbBranchDeleteFlags = CliCommand.Command.Config.Infer export const dbBranchDeleteCommand = Command.make("delete", config).pipe( Command.withDescription("Delete a branch."), Command.withShortDescription("Delete a branch"), + withPermissions({ status: "unmapped", reason: "go-delegated" }), Command.withHandler((flags) => dbBranchDelete(flags)), ); diff --git a/apps/cli/src/commands/db/branch/list/list.command.ts b/apps/cli/src/commands/db/branch/list/list.command.ts index f2d3eafbcc..512f39ebf0 100644 --- a/apps/cli/src/commands/db/branch/list/list.command.ts +++ b/apps/cli/src/commands/db/branch/list/list.command.ts @@ -1,6 +1,7 @@ import { Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { dbBranchList } from "./list.handler.ts"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = {} as const; @@ -9,5 +10,6 @@ export type DbBranchListFlags = CliCommand.Command.Config.Infer; export const dbBranchListCommand = Command.make("list", config).pipe( Command.withDescription("List branches."), Command.withShortDescription("List branches"), + withPermissions({ status: "unmapped", reason: "go-delegated" }), Command.withHandler((flags) => dbBranchList(flags)), ); diff --git a/apps/cli/src/commands/db/branch/switch/switch.command.ts b/apps/cli/src/commands/db/branch/switch/switch.command.ts index 277b2a8d38..d1191e18f7 100644 --- a/apps/cli/src/commands/db/branch/switch/switch.command.ts +++ b/apps/cli/src/commands/db/branch/switch/switch.command.ts @@ -1,6 +1,7 @@ import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { dbBranchSwitch } from "./switch.handler.ts"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = { branchName: Argument.String("branch name").pipe( @@ -13,5 +14,6 @@ export type DbBranchSwitchFlags = CliCommand.Command.Config.Infer export const dbBranchSwitchCommand = Command.make("switch", config).pipe( Command.withDescription("Switch the active branch."), Command.withShortDescription("Switch the active branch"), + withPermissions({ status: "unmapped", reason: "go-delegated" }), Command.withHandler((flags) => dbBranchSwitch(flags)), ); diff --git a/apps/cli/src/commands/db/remote/changes/changes.command.ts b/apps/cli/src/commands/db/remote/changes/changes.command.ts index 790306ca50..9440813a10 100644 --- a/apps/cli/src/commands/db/remote/changes/changes.command.ts +++ b/apps/cli/src/commands/db/remote/changes/changes.command.ts @@ -1,6 +1,7 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { dbRemoteChanges } from "./changes.handler.ts"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = { schema: Flag.String("schema").pipe( @@ -28,5 +29,6 @@ export type DbRemoteChangesFlags = CliCommand.Command.Config.Infer dbRemoteChanges(flags)), ); diff --git a/apps/cli/src/commands/gen/keys/keys.command.ts b/apps/cli/src/commands/gen/keys/keys.command.ts index 0106956f2d..f63a8d497e 100644 --- a/apps/cli/src/commands/gen/keys/keys.command.ts +++ b/apps/cli/src/commands/gen/keys/keys.command.ts @@ -1,6 +1,7 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { genKeys } from "./keys.handler.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = { projectRef: Flag.String("project-ref").pipe( @@ -20,5 +21,6 @@ export const genKeysCommand = Command.make("keys", config).pipe( 'Generate keys for preview branch. Deprecated: use "gen signing-key" instead.', ), Command.withShortDescription("Generate keys for preview branch (experimental)"), + withPermissions({ status: "unmapped", reason: "go-delegated" }), Command.withHandler((flags) => genKeys(flags)), ); diff --git a/apps/cli/tests/helpers/permission-pending-commands.ts b/apps/cli/tests/helpers/permission-pending-commands.ts deleted file mode 100644 index e552d7a766..0000000000 --- a/apps/cli/tests/helpers/permission-pending-commands.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** Leaf command paths that don't declare their permissions with `withPermissions` yet. */ -export const PENDING_COMMANDS: ReadonlyArray = [ - "db branch create", - "db branch delete", - "db branch list", - "db branch switch", - "db remote changes", - - "gen keys", -]; diff --git a/apps/cli/tests/helpers/permission-registry.unit.test.ts b/apps/cli/tests/helpers/permission-registry.unit.test.ts index c0d654372d..cba43c6be1 100644 --- a/apps/cli/tests/helpers/permission-registry.unit.test.ts +++ b/apps/cli/tests/helpers/permission-registry.unit.test.ts @@ -1,8 +1,6 @@ import { describe, expect, it } from "vitest"; import type { CommandPermissions } from "../../src/command-internal/command-permissions/model.ts"; -import { commandTreeFor } from "./command-flags.ts"; -import { PENDING_COMMANDS } from "./permission-pending-commands.ts"; import { applicableOperations, declaredPermissions } from "./permission-registry.ts"; describe("applicableOperations", () => { @@ -162,13 +160,7 @@ describe("declaredPermissions", () => { expect(declaredPermissions("stack list", { stackBackend: "stack" })._tag).toBe("Declared"); }); - it("reports a command in the tree that has no declaration as undeclared", () => { - const tree = commandTreeFor({}); - const pendingInTree = PENDING_COMMANDS.filter((path) => tree.has(path)); - // Fails when the last pending command is mapped; delete this test then. - expect(pendingInTree.length).toBeGreaterThan(0); - for (const path of pendingInTree) { - expect(declaredPermissions(path)).toEqual({ _tag: "Undeclared" }); - } + it("reports a command group, which carries no declaration, as undeclared", () => { + expect(declaredPermissions("db branch")).toEqual({ _tag: "Undeclared" }); }); }); From d78b1ed2dacfb337b0ac3b3e0c322ddd4c312ea2 Mon Sep 17 00:00:00 2001 From: avallete Date: Thu, 15 Oct 2026 15:54:45 +0200 Subject: [PATCH 26/26] test(cli): match Management API requests by parsed origin Co-Authored-By: Claude Opus 5.5 --- .../bootstrap/bootstrap.workdir-cache.integration.test.ts | 2 +- apps/cli/src/commands/link/link.integration.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts index 656f7e45f9..854fdb7914 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts @@ -333,7 +333,7 @@ describe("bootstrap linked-project cache location", () => { // requests are mapped. expect( api.requests - .filter(({ url }) => url.startsWith("https://api.supabase.com")) + .filter(({ url }) => new URL(url).origin === "https://api.supabase.com") .map(({ method, url }) => `${method} ${url.slice("https://api.supabase.com".length)}`), ).toEqual([ "GET /v1/organizations", diff --git a/apps/cli/src/commands/link/link.integration.test.ts b/apps/cli/src/commands/link/link.integration.test.ts index 004f70ebd3..6fbbd389e5 100644 --- a/apps/cli/src/commands/link/link.integration.test.ts +++ b/apps/cli/src/commands/link/link.integration.test.ts @@ -1593,7 +1593,7 @@ describe("link permissions", () => { const managementCalls = (api: { requests: ReadonlyArray<{ method: string; url: string }> }) => api.requests - .filter(({ url }) => url.startsWith(API)) + .filter(({ url }) => new URL(url).origin === API) .map(({ method, url }) => `${method} ${url.slice(API.length)}`); it.effect("links by --project-ref, reading the project, its keys, storage and the pooler", () => {