From 8919a0ee8db330f4f6b4ced001502ae4a091f8cb Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Mon, 24 Aug 2026 22:01:07 +0200 Subject: [PATCH 1/4] feat: add v2 hosted cost policy Adds the Supabase cost confirmation contract at policy version 2, together with the authoritative rate contract it reads. Version 2 takes consent from the wire action on a property-less requested schema. Version 1 read a Boolean out of the response body, so the two cannot interpret each other's state: the runtime rejects a version it does not own before it looks at any response, which makes a rolling deployment safe in both directions. Rates now come from the Management API. `AccountOperations` and `BranchingOperations` each gain one read-only creation-rate method, the API platform implements them against the new v2 endpoints, and `pricing.ts` becomes the adapter between an authoritative rate and the legacy cost shape. `PROJECT_COST_MONTHLY` is gone: `get_cost` and the legacy `create_project` check both read the organization's authoritative rate, which returns the same value. The hourly branch rate stays as one clearly named legacy value because the authoritative branch rate is scoped to a parent project and legacy `get_cost` is only given an organization; both halves of that legacy pair quote it, so a legacy confirmation still matches. No confirmation or creation path reads it, and no monthly-hours constant exists anywhere. Types generation gains v2 as an addition. The v1 pull and its complete output are untouched and byte-identical after regeneration. v2 is generated from a byte copy of the Management API v2 document at rates head af464cca85, because `/api/v2-json` is not served yet and a URL pull would make regeneration depend on a deployment. Both v2 artifacts join `src/management-api/types.ts` in the root formatter's ignore list, because a generated artifact is owned by its generator and hand-formatting one would break the next regeneration. Absorbs plan step C2's action-only consent implementation into this commit: `ElicitationPolicy` is a total interface, so a policy carrying the version this step defines does not compile without the `inputRequests` and `resolve` that step C2 specifies. It also absorbs plan step C3's final authoritative check, because the guard is the only reason the resolution type exists and a commit that defined the ceiling without spending it would ship an unused type. Breaking change for platform implementers: a `SupabasePlatform` with an `account` or `branching` implementation must add the matching creation-rate method. There is no fallback price to fall back to. --- .../scripts/generate-management-api-types.mjs | 61 +- .../scripts/specs/management-api-v2.spec.json | 5464 +++++++++++++++++ .../src/management-api/index.ts | 17 +- .../src/management-api/v2-types.ts | 3183 ++++++++++ .../src/platform/api-platform.test.ts | 156 + .../src/platform/api-platform.ts | 49 + .../mcp-server-supabase/src/platform/types.ts | 27 + .../src/policies/cost-confirmation.ts | 416 ++ packages/mcp-server-supabase/src/pricing.ts | 89 +- .../mcp-server-supabase/src/server.test.ts | 21 +- packages/mcp-server-supabase/src/server.ts | 4 +- .../src/tools/account-tools.ts | 44 +- .../src/tools/branching-tools.ts | 35 +- .../mcp-server-supabase/src/tools/util.ts | 28 +- packages/mcp-server-supabase/src/types.ts | 9 + packages/mcp-server-supabase/src/util.ts | 2 +- packages/mcp-server-supabase/test/mocks.ts | 111 +- 17 files changed, 9630 insertions(+), 86 deletions(-) create mode 100644 packages/mcp-server-supabase/scripts/specs/management-api-v2.spec.json create mode 100644 packages/mcp-server-supabase/src/management-api/v2-types.ts create mode 100644 packages/mcp-server-supabase/src/platform/api-platform.test.ts create mode 100644 packages/mcp-server-supabase/src/policies/cost-confirmation.ts diff --git a/packages/mcp-server-supabase/scripts/generate-management-api-types.mjs b/packages/mcp-server-supabase/scripts/generate-management-api-types.mjs index 20975e34..4a122fc9 100644 --- a/packages/mcp-server-supabase/scripts/generate-management-api-types.mjs +++ b/packages/mcp-server-supabase/scripts/generate-management-api-types.mjs @@ -1,8 +1,38 @@ +import { execFileSync } from 'node:child_process'; import { writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; import openapiTS, { astToString, UNKNOWN } from 'openapi-typescript'; -const SPEC_URL = 'https://api.supabase.com/api/v1-json'; -const OUTPUT_PATH = new URL('../src/management-api/types.ts', import.meta.url); +const V1_SPEC_URL = 'https://api.supabase.com/api/v1-json'; +const V1_OUTPUT_PATH = new URL( + '../src/management-api/types.ts', + import.meta.url +); +// The v2 document is vendored rather than fetched: `/api/v2-json` is not +// served yet, so a URL pull would make regeneration depend on a deployment. +// +// Provenance. The vendored file is generation input and is never hand-edited. +// It is the Management API repo's `api/apps/mgmt-api/specs/v2.spec.json` at +// rates head af464cca85, whose bytes hash to sha256 +// dfe848df7543e03d92f25c29a41829f980c99fdca8a326f5903aab1a83c70b26. The copy +// here differs from those bytes in whitespace only, because this repo's +// formatter checks every JSON file it can parse and a generated artifact that +// fails the formatter would fail CI. The documents are equal as documents: +// their canonical JSON (keys sorted, no insignificant whitespace) hashes to +// sha256 1b5a1548e91e81d1e6951019cd84ad2a8e193ca1fe2ef7f1578dd6a40c8ebb92 on +// both sides. +// +// Refresh, once `/api/v2-json` is served: copy the served document over this +// file, run `pnpm format`, then re-check content equality against the served +// bytes the same way, canonically rather than byte for byte. +const V2_SPEC_PATH = new URL( + './specs/management-api-v2.spec.json', + import.meta.url +); +const V2_OUTPUT_PATH = new URL( + '../src/management-api/v2-types.ts', + import.meta.url +); // Recursive "any JSON value" schemas produce a self-referential type that TypeScript // rejects (TS2502). These fields are opaque blobs anyway, so `unknown` is fine. @@ -14,13 +44,34 @@ function transform(schemaObject, options) { return undefined; } -const ast = await openapiTS(SPEC_URL, { transform }); +/** + * Generates one document's types. Each API version owns its own module: the v1 + * contract every current caller depends on stays exactly as it was, and v2 + * arrives beside it. + */ +async function generate(spec, outputPath) { + const ast = await openapiTS(spec, { transform }); -const output = `/** + const output = `/** * This file was auto-generated by openapi-typescript. * Do not make direct changes to the file. */ ${astToString(ast)}`; -writeFileSync(OUTPUT_PATH, output); + writeFileSync(outputPath, output); +} + +await generate(V1_SPEC_URL, V1_OUTPUT_PATH); +await generate(V2_SPEC_PATH, V2_OUTPUT_PATH); + +// The v1 output predates this repo's formatter and is excluded from it, so it +// stays exactly as the generator emits it. The v2 output is new and is not +// excluded, so it is formatted here instead: a freshly generated tree then +// passes `pnpm format:check`, and regeneration stays reproducible because the +// formatter is idempotent. +execFileSync( + 'pnpm', + ['exec', 'biome', 'format', '--write', fileURLToPath(V2_OUTPUT_PATH)], + { stdio: 'inherit' } +); diff --git a/packages/mcp-server-supabase/scripts/specs/management-api-v2.spec.json b/packages/mcp-server-supabase/scripts/specs/management-api-v2.spec.json new file mode 100644 index 00000000..b218638e --- /dev/null +++ b/packages/mcp-server-supabase/scripts/specs/management-api-v2.spec.json @@ -0,0 +1,5464 @@ +{ + "components": { + "schemas": { + "CreateLogDrainRequestOpenApi": { + "properties": { + "data": { + "properties": { + "attributes": { + "properties": { + "backend_type": { + "enum": [ + "axiom", + "bigquery", + "clickhouse", + "datadog", + "last9", + "loki", + "otlp", + "postgres", + "s3", + "sentry", + "syslog", + "webhook" + ], + "type": "string" + }, + "config": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "hostname": { + "type": "string" + }, + "password": { + "nullable": true, + "type": "string" + }, + "port": { + "nullable": true, + "type": "number" + }, + "schema": { + "type": "string" + }, + "url": { + "nullable": true, + "type": "string" + }, + "username": { + "nullable": true, + "type": "string" + } + }, + "title": "postgres", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "gzip": { + "type": "boolean" + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "http": { + "enum": ["http1", "http2"], + "type": "string" + }, + "url": { + "type": "string" + } + }, + "title": "webhook", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "dataset_id": { + "type": "string" + }, + "project_id": { + "type": "string" + } + }, + "title": "bigquery", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "api_key": { + "type": "string" + }, + "region": { + "type": "string" + } + }, + "title": "datadog", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "headers": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "password": { + "nullable": true, + "type": "string" + }, + "url": { + "type": "string" + }, + "username": { + "nullable": true, + "type": "string" + } + }, + "title": "loki", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "dsn": { + "type": "string" + } + }, + "title": "sentry", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "api_token": { + "type": "string" + }, + "dataset_name": { + "type": "string" + }, + "domain": { + "type": "string" + } + }, + "title": "axiom", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "ca_cert": { + "type": "string" + }, + "cipher_key": { + "type": "string" + }, + "client_cert": { + "type": "string" + }, + "client_key": { + "type": "string" + }, + "host": { + "type": "string" + }, + "port": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "structured_data": { + "type": "string" + }, + "tls": { + "default": false, + "type": "boolean" + } + }, + "title": "syslog", + "type": "object" + } + ] + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["backend_type", "config", "name"], + "type": "object" + }, + "type": { + "description": "Resource type.", + "enum": ["log_drain"], + "type": "string" + } + }, + "required": ["attributes", "type"], + "type": "object" + } + }, + "required": ["data"], + "type": "object" + }, + "ErrorResponseBody": { + "properties": { + "error": { + "$ref": "#/components/schemas/ErrorResponseBodyAPIErrorObject" + } + }, + "required": ["error"], + "type": "object" + }, + "ErrorResponseBodyAPIErrorObject": { + "properties": { + "code": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "issues": { + "items": { + "$ref": "#/components/schemas/ErrorResponseBodyAPIErrorObject" + }, + "type": "array" + }, + "links": { + "additionalProperties": { + "properties": { + "describedby": { + "type": "string" + }, + "href": { + "type": "string" + }, + "meta": { + "additionalProperties": {}, + "type": "object" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": ["href"], + "type": "object" + }, + "type": "object" + }, + "message": { + "type": "string" + }, + "meta": { + "additionalProperties": {}, + "type": "object" + } + }, + "ref": "APIErrorObject", + "required": ["code", "message"], + "type": "object" + }, + "ListLogDrainsResponse": { + "properties": { + "data": { + "items": { + "properties": { + "attributes": { + "properties": { + "backend_type": { + "enum": [ + "axiom", + "bigquery", + "clickhouse", + "datadog", + "last9", + "loki", + "otlp", + "postgres", + "s3", + "sentry", + "syslog", + "webhook" + ], + "type": "string" + }, + "config": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "hostname": { + "type": "string" + }, + "password": { + "nullable": true, + "type": "string" + }, + "port": { + "nullable": true, + "type": "number" + }, + "schema": { + "type": "string" + }, + "url": { + "nullable": true, + "type": "string" + }, + "username": { + "nullable": true, + "type": "string" + } + }, + "title": "postgres", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "gzip": { + "type": "boolean" + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "http": { + "enum": ["http1", "http2"], + "type": "string" + }, + "url": { + "type": "string" + } + }, + "title": "webhook", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "dataset_id": { + "type": "string" + }, + "project_id": { + "type": "string" + } + }, + "title": "bigquery", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "api_key": { + "type": "string" + }, + "region": { + "type": "string" + } + }, + "title": "datadog", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "headers": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "password": { + "nullable": true, + "type": "string" + }, + "url": { + "type": "string" + }, + "username": { + "nullable": true, + "type": "string" + } + }, + "title": "loki", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "dsn": { + "type": "string" + } + }, + "title": "sentry", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "api_token": { + "type": "string" + }, + "dataset_name": { + "type": "string" + }, + "domain": { + "type": "string" + } + }, + "title": "axiom", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "ca_cert": { + "type": "string" + }, + "cipher_key": { + "type": "string" + }, + "client_cert": { + "type": "string" + }, + "client_key": { + "type": "string" + }, + "host": { + "type": "string" + }, + "port": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "structured_data": { + "type": "string" + }, + "tls": { + "default": false, + "type": "boolean" + } + }, + "title": "syslog", + "type": "object" + } + ] + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["backend_type", "config", "name"], + "type": "object" + }, + "id": { + "type": "string" + }, + "type": { + "description": "Resource type.", + "enum": ["log_drain"], + "type": "string" + } + }, + "required": ["attributes", "id", "type"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["data"], + "type": "object" + }, + "LogDrainResponse": { + "properties": { + "data": { + "properties": { + "attributes": { + "properties": { + "backend_type": { + "enum": [ + "axiom", + "bigquery", + "clickhouse", + "datadog", + "last9", + "loki", + "otlp", + "postgres", + "s3", + "sentry", + "syslog", + "webhook" + ], + "type": "string" + }, + "config": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "hostname": { + "type": "string" + }, + "password": { + "nullable": true, + "type": "string" + }, + "port": { + "nullable": true, + "type": "number" + }, + "schema": { + "type": "string" + }, + "url": { + "nullable": true, + "type": "string" + }, + "username": { + "nullable": true, + "type": "string" + } + }, + "title": "postgres", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "gzip": { + "type": "boolean" + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "http": { + "enum": ["http1", "http2"], + "type": "string" + }, + "url": { + "type": "string" + } + }, + "title": "webhook", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "dataset_id": { + "type": "string" + }, + "project_id": { + "type": "string" + } + }, + "title": "bigquery", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "api_key": { + "type": "string" + }, + "region": { + "type": "string" + } + }, + "title": "datadog", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "headers": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "password": { + "nullable": true, + "type": "string" + }, + "url": { + "type": "string" + }, + "username": { + "nullable": true, + "type": "string" + } + }, + "title": "loki", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "dsn": { + "type": "string" + } + }, + "title": "sentry", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "api_token": { + "type": "string" + }, + "dataset_name": { + "type": "string" + }, + "domain": { + "type": "string" + } + }, + "title": "axiom", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "ca_cert": { + "type": "string" + }, + "cipher_key": { + "type": "string" + }, + "client_cert": { + "type": "string" + }, + "client_key": { + "type": "string" + }, + "host": { + "type": "string" + }, + "port": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "structured_data": { + "type": "string" + }, + "tls": { + "default": false, + "type": "boolean" + } + }, + "title": "syslog", + "type": "object" + } + ] + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["backend_type", "config", "name"], + "type": "object" + }, + "id": { + "type": "string" + }, + "type": { + "description": "Resource type.", + "enum": ["log_drain"], + "type": "string" + } + }, + "required": ["attributes", "id", "type"], + "type": "object" + } + }, + "required": ["data"], + "type": "object" + }, + "OrganizationMemberRoleResponse": { + "properties": { + "data": { + "properties": { + "attributes": { + "properties": { + "name": { + "description": "Role name. For project-scoped assignments this is the base role name.", + "example": "developer", + "type": "string" + }, + "projects": { + "description": "Project refs this role is scoped to. Empty array for org-level roles.", + "items": { + "properties": { + "name": { + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["name", "ref"], + "type": "object" + }, + "type": "array" + }, + "scope": { + "description": "Whether this role applies org-wide or is scoped to specific projects for the user.", + "enum": ["organization", "project"], + "type": "string" + } + }, + "required": ["name", "projects", "scope"], + "type": "object" + }, + "type": { + "description": "Resource type.", + "enum": ["organization_member_role"], + "type": "string" + } + }, + "required": ["attributes", "type"], + "type": "object" + } + }, + "required": ["data"], + "type": "object" + }, + "UpdateLogDrainRequestOpenApi": { + "properties": { + "data": { + "properties": { + "attributes": { + "properties": { + "backend_type": { + "enum": [ + "axiom", + "bigquery", + "clickhouse", + "datadog", + "last9", + "loki", + "otlp", + "postgres", + "s3", + "sentry", + "syslog", + "webhook" + ], + "type": "string" + }, + "config": { + "anyOf": [ + { + "additionalProperties": false, + "properties": { + "hostname": { + "type": "string" + }, + "password": { + "nullable": true, + "type": "string" + }, + "port": { + "nullable": true, + "type": "number" + }, + "schema": { + "type": "string" + }, + "url": { + "nullable": true, + "type": "string" + }, + "username": { + "nullable": true, + "type": "string" + } + }, + "title": "postgres", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "gzip": { + "type": "boolean" + }, + "headers": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "http": { + "enum": ["http1", "http2"], + "type": "string" + }, + "url": { + "type": "string" + } + }, + "title": "webhook", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "dataset_id": { + "type": "string" + }, + "project_id": { + "type": "string" + } + }, + "title": "bigquery", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "api_key": { + "type": "string" + }, + "region": { + "type": "string" + } + }, + "title": "datadog", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "headers": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "password": { + "nullable": true, + "type": "string" + }, + "url": { + "type": "string" + }, + "username": { + "nullable": true, + "type": "string" + } + }, + "title": "loki", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "dsn": { + "type": "string" + } + }, + "title": "sentry", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "api_token": { + "type": "string" + }, + "dataset_name": { + "type": "string" + }, + "domain": { + "type": "string" + } + }, + "title": "axiom", + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "ca_cert": { + "type": "string" + }, + "cipher_key": { + "type": "string" + }, + "client_cert": { + "type": "string" + }, + "client_key": { + "type": "string" + }, + "host": { + "type": "string" + }, + "port": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "structured_data": { + "type": "string" + }, + "tls": { + "default": false, + "type": "boolean" + } + }, + "title": "syslog", + "type": "object" + } + ] + }, + "description": { + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": ["backend_type"], + "type": "object" + }, + "type": { + "description": "Resource type.", + "enum": ["log_drain"], + "type": "string" + } + }, + "required": ["attributes", "type"], + "type": "object" + } + }, + "required": ["data"], + "type": "object" + }, + "V2AssignOrganizationMemberRoleRequest": { + "properties": { + "data": { + "properties": { + "attributes": { + "properties": { + "projects": { + "description": "The projects to assign a project-scoped role for. If omitted, assigns an org-wide role.", + "items": { + "properties": { + "ref": { + "description": "Project ref", + "example": "abcjuqabhgwjjutfvtpa", + "type": "string" + } + }, + "required": ["ref"], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + "role": { + "description": "Role name to assign. Must be one of: owner, administrator, developer, read-only. Must be on a Team or Enterprise plan to use the read-only role.", + "enum": [ + "administrator", + "developer", + "owner", + "read-only" + ], + "example": "developer", + "type": "string" + } + }, + "required": ["role"], + "type": "object" + }, + "type": { + "description": "Resource type.", + "enum": ["organization_member_role"], + "type": "string" + } + }, + "required": ["attributes", "type"], + "type": "object" + } + }, + "required": ["data"], + "type": "object" + }, + "V2BranchCreationRateResponse": { + "properties": { + "data": { + "properties": { + "attributes": { + "additionalProperties": {}, + "properties": { + "amount": { + "description": "Authoritative rate a new branch adds to the organization. Zero is authoritative and means the branch can be created without an additional charge.", + "example": 0.01344, + "type": "number" + }, + "currency": { + "description": "ISO 4217 currency of the amount.", + "example": "USD", + "type": "string" + }, + "organization_slug": { + "description": "Organization the charge lands on.", + "example": "my-org", + "type": "string" + }, + "parent_project_ref": { + "description": "Project the branch would be created under.", + "example": "abcdefghijklmnopqrst", + "type": "string" + }, + "plan_id": { + "description": "Plan that decided the amount.", + "example": "pro", + "type": "string" + }, + "recurrence": { + "description": "Interval the amount recurs at.", + "enum": ["hourly", "monthly"], + "example": "hourly", + "type": "string" + } + }, + "required": [ + "amount", + "currency", + "organization_slug", + "parent_project_ref", + "plan_id", + "recurrence" + ], + "type": "object" + }, + "type": { + "description": "Resource type.", + "enum": ["branch_creation_rate"], + "type": "string" + } + }, + "required": ["attributes", "type"], + "type": "object" + } + }, + "required": ["data"], + "type": "object" + }, + "V2CreateInvitationsRequest": { + "properties": { + "data": { + "items": { + "properties": { + "attributes": { + "properties": { + "email": { + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "type": "string" + }, + "projects": { + "description": "The projects to limit a user to. If omitted, user will have org-wide access with the provided role.", + "items": { + "properties": { + "ref": { + "description": "Project ref", + "example": "abcjuqabhgwjjutfvtpa", + "type": "string" + } + }, + "required": ["ref"], + "type": "object" + }, + "minItems": 1, + "type": "array" + }, + "require_sso": { + "type": "boolean" + }, + "role": { + "description": "Role name to assign. Must be on a Team or Enterprise plan to use the read-only role.", + "enum": [ + "administrator", + "developer", + "owner", + "read-only" + ], + "example": "developer", + "type": "string" + } + }, + "required": ["email", "role"], + "type": "object" + }, + "type": { + "description": "Resource type.", + "enum": ["organization_invitation"], + "type": "string" + } + }, + "required": ["attributes", "type"], + "type": "object" + }, + "maxItems": 50, + "minItems": 1, + "type": "array" + } + }, + "required": ["data"], + "type": "object" + }, + "V2CreateInvitationsResponse": { + "properties": { + "data": { + "items": { + "properties": { + "attributes": { + "properties": { + "email": { + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "type": "string" + } + }, + "required": ["email"], + "type": "object" + }, + "type": { + "description": "Resource type.", + "enum": ["organization_invitation"], + "type": "string" + } + }, + "required": ["attributes", "type"], + "type": "object" + }, + "type": "array" + }, + "error": { + "properties": { + "code": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "issues": { + "items": { + "properties": { + "code": { + "type": "string" + }, + "description": { + "type": "string" + }, + "id": { + "type": "string" + }, + "links": { + "additionalProperties": { + "properties": { + "describedby": { + "type": "string" + }, + "href": { + "type": "string" + }, + "meta": { + "additionalProperties": {}, + "type": "object" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": ["href"], + "type": "object" + }, + "type": "object" + }, + "message": { + "type": "string" + }, + "meta": { + "properties": { + "email": { + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "type": "string" + } + }, + "required": ["email"], + "type": "object" + } + }, + "required": ["code", "message", "meta"], + "type": "object" + }, + "type": "array" + }, + "links": { + "additionalProperties": { + "properties": { + "describedby": { + "type": "string" + }, + "href": { + "type": "string" + }, + "meta": { + "additionalProperties": {}, + "type": "object" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + } + }, + "required": ["href"], + "type": "object" + }, + "type": "object" + }, + "message": { + "type": "string" + }, + "meta": { + "additionalProperties": {}, + "type": "object" + } + }, + "required": ["code", "message"], + "type": "object" + } + }, + "required": ["data"], + "type": "object" + }, + "V2CreatePrivateLinkAssociationRequest": { + "properties": { + "data": { + "properties": { + "attributes": { + "properties": { + "account_name": { + "description": "Optional human-readable name for the AWS account.", + "maxLength": 128, + "type": "string" + }, + "aws_account_id": { + "description": "The AWS account ID to add to the project PrivateLink share.", + "maxLength": 12, + "minLength": 12, + "pattern": "^\\d{12}$", + "type": "string" + }, + "database_identifier": { + "description": "Identifier of the read replica this PrivateLink share should target. Omit to target the primary database.", + "type": "string" + } + }, + "required": ["aws_account_id"], + "type": "object" + }, + "type": { + "description": "Resource type.", + "enum": ["private_link_association"], + "type": "string" + } + }, + "required": ["attributes", "type"], + "type": "object" + } + }, + "required": ["data"], + "type": "object" + }, + "V2DeleteInvitationsRequest": { + "properties": { + "data": { + "items": { + "properties": { + "attributes": { + "properties": { + "email": { + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "type": "string" + } + }, + "required": ["email"], + "type": "object" + }, + "type": { + "description": "Resource type.", + "enum": ["organization_invitation"], + "type": "string" + } + }, + "required": ["attributes", "type"], + "type": "object" + }, + "maxItems": 100, + "minItems": 1, + "type": "array" + } + }, + "required": ["data"], + "type": "object" + }, + "V2DeleteInvitationsResponse": { + "properties": { + "data": { + "items": { + "properties": { + "attributes": { + "properties": { + "email": { + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "type": "string" + } + }, + "required": ["email"], + "type": "object" + }, + "type": { + "description": "Resource type.", + "enum": ["organization_invitation"], + "type": "string" + } + }, + "required": ["attributes", "type"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["data"], + "type": "object" + }, + "V2DeployWorkerRequest": { + "properties": { + "data": { + "properties": { + "attributes": { + "properties": { + "context_upload_id": { + "description": "Id of a build context staged through the uploads endpoint. Required unless `runtime` is set.", + "type": "string" + }, + "spec": { + "properties": { + "exposure": { + "example": "public", + "type": "string" + }, + "instances": { + "example": 1, + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "runtime": { + "example": "node", + "type": "string" + }, + "size": { + "example": "2gb-1vcpu", + "type": "string" + } + }, + "required": ["exposure", "instances", "size"], + "type": "object" + } + }, + "required": ["spec"], + "type": "object" + }, + "type": { + "description": "Resource type.", + "enum": ["project_worker"], + "type": "string" + } + }, + "required": ["attributes", "type"], + "type": "object" + } + }, + "required": ["data"], + "type": "object" + }, + "V2ListGitHubConnectionsResponse": { + "properties": { + "data": { + "items": { + "properties": { + "attributes": { + "properties": { + "branch_limit": { + "description": "Maximum number of preview branches", + "type": "number" + }, + "inserted_at": { + "description": "When the connection was created", + "type": "string" + }, + "installation_id": { + "description": "GitHub App installation id", + "type": "number" + }, + "new_branch_per_pr": { + "description": "Whether a preview branch is created for every pull request", + "type": "boolean" + }, + "project": { + "description": "The connected Supabase project", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + }, + "ref": { + "description": "Project ref", + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + } + }, + "required": ["id", "name", "ref"], + "type": "object" + }, + "repository": { + "description": "The connected GitHub repository", + "properties": { + "id": { + "type": "number" + }, + "name": { + "type": "string" + } + }, + "required": ["id", "name"], + "type": "object" + }, + "supabase_changes_only": { + "description": "Whether branches are only created for changes under `supabase/`", + "type": "boolean" + }, + "updated_at": { + "description": "When the connection was last updated", + "type": "string" + }, + "user": { + "description": "The user who created the connection, if still known", + "nullable": true, + "properties": { + "id": { + "type": "number" + }, + "primary_email": { + "nullable": true, + "type": "string" + }, + "username": { + "type": "string" + } + }, + "required": ["id", "primary_email", "username"], + "type": "object" + }, + "workdir": { + "description": "Directory within the repository the project lives in", + "type": "string" + } + }, + "required": [ + "branch_limit", + "inserted_at", + "installation_id", + "new_branch_per_pr", + "project", + "repository", + "supabase_changes_only", + "updated_at", + "user", + "workdir" + ], + "type": "object" + }, + "id": { + "description": "Connection id.", + "example": "7", + "type": "string" + }, + "type": { + "description": "Resource type.", + "enum": ["github_connection"], + "type": "string" + } + }, + "required": ["attributes", "id", "type"], + "type": "object" + }, + "type": "array" + }, + "links": { + "properties": { + "first": { + "description": "URL path to the first page if available.", + "example": "/v2/organizations/my-org/integrations/github/connections?page[size]=10", + "nullable": true, + "type": "string" + }, + "last": { + "description": "URL path to the last page if available.", + "example": "/v2/organizations/my-org/integrations/github/connections?page[size]=10&page[after]=019adf7d-4513-71ba-b264-21900edb4295", + "nullable": true, + "type": "string" + }, + "next": { + "description": "URL path to the next page.", + "example": "/v2/organizations/my-org/integrations/github/connections?page[size]=10&page[after]=019adf7d-4513-7062-b292-78b86cc470a4", + "nullable": true, + "type": "string" + }, + "prev": { + "description": "URL path to the previous page.", + "example": "/v2/organizations/my-org/integrations/github/connections?page[size]=10&page[before]=019adf7d-4513-74c5-bb9a-f1bc0f7a95d7", + "nullable": true, + "type": "string" + } + }, + "required": ["next", "prev"], + "type": "object" + } + }, + "required": ["data", "links"], + "type": "object" + }, + "V2ListMembersResponse": { + "properties": { + "data": { + "items": { + "properties": { + "attributes": { + "properties": { + "avatar_url": { + "description": "Member's avatar URL", + "nullable": true, + "type": "string" + }, + "is_sso_user": { + "description": "Whether this member is a Single Sign-On user", + "type": "boolean" + }, + "mfa_enabled": { + "description": "Whether Multi-Factor Authentication is enabled for this member", + "type": "boolean" + }, + "primary_email": { + "description": "Member's primary email", + "nullable": true, + "type": "string" + }, + "roles": { + "description": "Roles assigned to this member. Includes both org-level and project-scoped roles.", + "items": { + "properties": { + "name": { + "description": "Role name. For project-scoped roles this is the base role name.", + "example": "developer", + "type": "string" + }, + "projects": { + "description": "Project refs this role is scoped to. Empty array for org-level roles.", + "items": { + "properties": { + "name": { + "type": "string" + }, + "ref": { + "type": "string" + } + }, + "required": ["name", "ref"], + "type": "object" + }, + "type": "array" + }, + "scope": { + "description": "Whether this role applies org-wide or is scoped to specific projects for the user.", + "enum": ["organization", "project"], + "type": "string" + } + }, + "required": ["name", "projects", "scope"], + "type": "object" + }, + "type": "array" + }, + "username": { + "description": "Member's username", + "nullable": true, + "type": "string" + } + }, + "required": [ + "avatar_url", + "is_sso_user", + "mfa_enabled", + "primary_email", + "roles", + "username" + ], + "type": "object" + }, + "id": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12})$", + "type": "string" + }, + "type": { + "description": "Resource type.", + "enum": ["organization_member"], + "type": "string" + } + }, + "required": ["attributes", "id", "type"], + "type": "object" + }, + "type": "array" + }, + "links": { + "properties": { + "first": { + "description": "URL path to the first page if available.", + "example": "/v2/organizations/my-org/members?page[size]=10", + "nullable": true, + "type": "string" + }, + "last": { + "description": "URL path to the last page if available.", + "example": "/v2/organizations/my-org/members?page[size]=10&page[after]=019adf7d-4513-71ba-b264-21900edb4295", + "nullable": true, + "type": "string" + }, + "next": { + "description": "URL path to the next page.", + "example": "/v2/organizations/my-org/members?page[size]=10&page[after]=019adf7d-4513-7062-b292-78b86cc470a4", + "nullable": true, + "type": "string" + }, + "prev": { + "description": "URL path to the previous page.", + "example": "/v2/organizations/my-org/members?page[size]=10&page[before]=019adf7d-4513-74c5-bb9a-f1bc0f7a95d7", + "nullable": true, + "type": "string" + } + }, + "required": ["next", "prev"], + "type": "object" + } + }, + "required": ["data", "links"], + "type": "object" + }, + "V2ListPrivateLinkAssociationsResponse": { + "properties": { + "data": { + "items": { + "properties": { + "attributes": { + "properties": { + "account_name": { + "description": "Human-readable name for the AWS account.", + "type": "string" + }, + "aws_account_id": { + "description": "The AWS account ID this PrivateLink share is associated with.", + "maxLength": 12, + "minLength": 12, + "pattern": "^\\d{12}$", + "type": "string" + }, + "database_identifier": { + "description": "Identifier of the database this PrivateLink share targets - the project ref for the primary, or the read replica identifier.", + "type": "string" + }, + "database_type": { + "description": "Whether this PrivateLink share targets the primary database or a read replica.", + "enum": ["PRIMARY", "READ_REPLICA"], + "type": "string" + }, + "resource_access_manager_resource_config_arn": { + "description": "ARN of the AWS VPC Lattice resource configuration backing this PrivateLink share.", + "type": "string" + }, + "resource_access_manager_resource_config_id": { + "description": "ID of the AWS VPC Lattice resource configuration backing this PrivateLink share.", + "type": "string" + }, + "resource_access_manager_share_arn": { + "description": "ARN of the AWS Resource Access Manager resource share for this association.", + "type": "string" + }, + "shared_at": { + "description": "The time and date at which the AWS Resource Share Association was requested from Supabase. `null` means that the association was not yet requested while the PrivateLink Association is pending.", + "format": "date-time", + "nullable": true, + "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))$", + "type": "string" + }, + "status": { + "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", + "enum": [ + "ASSOCIATION_ACCEPTED", + "ASSOCIATION_REQUEST_EXPIRED", + "CREATING", + "CREATION_FAILED", + "DELETING", + "READY" + ], + "type": "string" + } + }, + "required": [ + "aws_account_id", + "database_identifier", + "database_type", + "shared_at", + "status" + ], + "type": "object" + }, + "id": { + "type": "string" + }, + "type": { + "description": "Resource type.", + "enum": ["private_link_association"], + "type": "string" + } + }, + "required": ["attributes", "id", "type"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["data"], + "type": "object" + }, + "V2ListProjectsResponse": { + "properties": { + "data": { + "items": { + "properties": { + "attributes": { + "properties": { + "cloud_provider": { + "description": "Cloud provider hosting the project", + "type": "string" + }, + "databases": { + "description": "The project's databases including compute and disk attributes.", + "items": { + "properties": { + "cloud_provider": { + "type": "string" + }, + "disk_last_modified_at": { + "type": "string" + }, + "disk_throughput_mbps": { + "type": "number" + }, + "disk_type": { + "enum": ["gp3", "io2"], + "type": "string" + }, + "disk_volume_size_gb": { + "type": "number" + }, + "identifier": { + "type": "string" + }, + "infra_compute_size": { + "enum": [ + "12xlarge", + "16xlarge", + "24xlarge", + "24xlarge_high_memory", + "24xlarge_optimized_cpu", + "24xlarge_optimized_memory", + "2xlarge", + "48xlarge", + "48xlarge_high_memory", + "48xlarge_optimized_cpu", + "48xlarge_optimized_memory", + "4xlarge", + "8xlarge", + "large", + "medium", + "micro", + "nano", + "pico", + "small", + "xlarge" + ], + "type": "string" + }, + "region": { + "nullable": true, + "type": "string" + }, + "status": { + "enum": [ + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "GOING_DOWN", + "INIT_FAILED", + "INIT_READ_REPLICA", + "INIT_READ_REPLICA_FAILED", + "REMOVED", + "RESIZING", + "RESTARTING", + "RESTORING", + "UNKNOWN" + ], + "type": "string" + }, + "type": { + "enum": ["PRIMARY", "READ_REPLICA"], + "type": "string" + } + }, + "required": [ + "cloud_provider", + "identifier", + "region", + "status", + "type" + ], + "type": "object" + }, + "type": "array" + }, + "inserted_at": { + "description": "When the project was created", + "type": "string" + }, + "name": { + "description": "Project name", + "type": "string" + }, + "region": { + "description": "Region the project is hosted in", + "type": "string" + }, + "status": { + "description": "Project status", + "enum": [ + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "GOING_DOWN", + "INACTIVE", + "INIT_FAILED", + "PAUSE_FAILED", + "PAUSING", + "REMOVED", + "RESIZING", + "RESTARTING", + "RESTORE_FAILED", + "RESTORING", + "UNKNOWN", + "UPGRADING" + ], + "type": "string" + } + }, + "required": [ + "cloud_provider", + "databases", + "inserted_at", + "name", + "region", + "status" + ], + "type": "object" + }, + "id": { + "description": "Project ref", + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + }, + "type": { + "description": "Resource type.", + "enum": ["project"], + "type": "string" + } + }, + "required": ["attributes", "id", "type"], + "type": "object" + }, + "type": "array" + }, + "links": { + "properties": { + "first": { + "description": "URL path to the first page if available.", + "example": "/v2/organizations/my-org/projects?page[size]=10", + "nullable": true, + "type": "string" + }, + "last": { + "description": "URL path to the last page if available.", + "example": "/v2/organizations/my-org/projects?page[size]=10&page[after]=019adf7d-4513-71ba-b264-21900edb4295", + "nullable": true, + "type": "string" + }, + "next": { + "description": "URL path to the next page.", + "example": "/v2/organizations/my-org/projects?page[size]=10&page[after]=019adf7d-4513-7062-b292-78b86cc470a4", + "nullable": true, + "type": "string" + }, + "prev": { + "description": "URL path to the previous page.", + "example": "/v2/organizations/my-org/projects?page[size]=10&page[before]=019adf7d-4513-74c5-bb9a-f1bc0f7a95d7", + "nullable": true, + "type": "string" + } + }, + "required": ["next", "prev"], + "type": "object" + } + }, + "required": ["data", "links"], + "type": "object" + }, + "V2ListRolesResponse": { + "properties": { + "data": { + "items": { + "properties": { + "attributes": { + "properties": { + "name": { + "description": "Role name.", + "example": "developer", + "type": "string" + } + }, + "required": ["name"], + "type": "object" + }, + "type": { + "description": "Resource type.", + "enum": ["organization_role"], + "type": "string" + } + }, + "required": ["attributes", "type"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["data"], + "type": "object" + }, + "V2ListWorkersResponse": { + "properties": { + "data": { + "items": { + "properties": { + "attributes": { + "properties": { + "build_state": { + "enum": ["active", "building", "failed"], + "type": "string" + }, + "deleting": { + "type": "boolean" + }, + "image_version": { + "type": "string" + }, + "instances": { + "properties": { + "declared": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "live": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "ready": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "stale": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + } + }, + "required": ["declared", "live", "ready", "stale"], + "type": "object" + }, + "instances_error": { + "type": "string" + }, + "secret_generation": { + "type": "string" + }, + "spec": { + "properties": { + "exposure": { + "example": "public", + "type": "string" + }, + "instances": { + "example": 1, + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "runtime": { + "example": "node", + "type": "string" + }, + "size": { + "example": "2gb-1vcpu", + "type": "string" + } + }, + "required": ["exposure", "instances", "size"], + "type": "object" + }, + "state_reason": { + "type": "string" + } + }, + "required": ["build_state", "secret_generation", "spec"], + "type": "object" + }, + "id": { + "description": "Worker name.", + "example": "hello-world", + "type": "string" + }, + "type": { + "description": "Resource type.", + "enum": ["project_worker"], + "type": "string" + } + }, + "required": ["attributes", "id", "type"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["data"], + "type": "object" + }, + "V2PreviewProjectTransferResponse": { + "properties": { + "data": { + "properties": { + "attributes": { + "properties": { + "errors": { + "items": { + "properties": { + "key": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["key", "message"], + "type": "object" + }, + "type": "array" + }, + "info": { + "items": { + "properties": { + "key": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["key", "message"], + "type": "object" + }, + "type": "array" + }, + "valid": { + "type": "boolean" + }, + "warnings": { + "items": { + "properties": { + "key": { + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": ["key", "message"], + "type": "object" + }, + "type": "array" + } + }, + "required": ["errors", "info", "valid", "warnings"], + "type": "object" + }, + "type": { + "description": "Resource type.", + "enum": ["project_transfer_result"], + "type": "string" + } + }, + "required": ["attributes", "type"], + "type": "object" + } + }, + "required": ["data"], + "type": "object" + }, + "V2PrivateLinkAssociationResponse": { + "properties": { + "data": { + "properties": { + "attributes": { + "properties": { + "account_name": { + "description": "Human-readable name for the AWS account.", + "type": "string" + }, + "aws_account_id": { + "description": "The AWS account ID this PrivateLink share is associated with.", + "maxLength": 12, + "minLength": 12, + "pattern": "^\\d{12}$", + "type": "string" + }, + "database_identifier": { + "description": "Identifier of the database this PrivateLink share targets - the project ref for the primary, or the read replica identifier.", + "type": "string" + }, + "database_type": { + "description": "Whether this PrivateLink share targets the primary database or a read replica.", + "enum": ["PRIMARY", "READ_REPLICA"], + "type": "string" + }, + "resource_access_manager_resource_config_arn": { + "description": "ARN of the AWS VPC Lattice resource configuration backing this PrivateLink share.", + "type": "string" + }, + "resource_access_manager_resource_config_id": { + "description": "ID of the AWS VPC Lattice resource configuration backing this PrivateLink share.", + "type": "string" + }, + "resource_access_manager_share_arn": { + "description": "ARN of the AWS Resource Access Manager resource share for this association.", + "type": "string" + }, + "shared_at": { + "description": "The time and date at which the AWS Resource Share Association was requested from Supabase. `null` means that the association was not yet requested while the PrivateLink Association is pending.", + "format": "date-time", + "nullable": true, + "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))$", + "type": "string" + }, + "status": { + "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", + "enum": [ + "ASSOCIATION_ACCEPTED", + "ASSOCIATION_REQUEST_EXPIRED", + "CREATING", + "CREATION_FAILED", + "DELETING", + "READY" + ], + "type": "string" + } + }, + "required": [ + "aws_account_id", + "database_identifier", + "database_type", + "shared_at", + "status" + ], + "type": "object" + }, + "id": { + "type": "string" + }, + "type": { + "description": "Resource type.", + "enum": ["private_link_association"], + "type": "string" + } + }, + "required": ["attributes", "id", "type"], + "type": "object" + } + }, + "required": ["data"], + "type": "object" + }, + "V2ProjectConfigResponse": { + "properties": { + "data": { + "properties": { + "attributes": { + "properties": { + "api": { + "properties": { + "db_extra_search_path": { + "type": "string" + }, + "db_pool": { + "description": "If `null`, no pool size is written to the project's PostgREST config and PostgREST's own default applies. The platform does not pick a value here.", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "nullable": true, + "type": "integer" + }, + "db_pool_acquisition_timeout": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "db_schema": { + "description": "Schemas exposed through the Data API", + "type": "string" + }, + "max_rows": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + } + }, + "required": [ + "db_extra_search_path", + "db_pool", + "db_pool_acquisition_timeout", + "db_schema", + "max_rows" + ], + "type": "object" + }, + "auth": { + "additionalProperties": {}, + "description": "Effective Auth config, keyed by lowercased GoTrue setting name and resolved through the `gotrue_config` view, so a setting the project has never overridden is reported at its platform default. Secrets are returned as an HMAC of their value, never in plaintext.", + "type": "object" + }, + "database": { + "properties": { + "major_version": { + "description": "The major Postgres version the database runs. `17` covers both Postgres 17 and Oriole on 17, since Oriole is a storage engine rather than a version.", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "network_restrictions": { + "properties": { + "allowed_cidrs": { + "items": { + "properties": { + "address": { + "type": "string" + }, + "type": { + "enum": ["v4", "v6"], + "type": "string" + } + }, + "required": ["address", "type"], + "type": "object" + }, + "type": "array" + }, + "applied_at": { + "type": "string" + }, + "entitlement": { + "enum": ["allowed", "disallowed"], + "type": "string" + }, + "status": { + "description": "Whether the allowlist below is applied to the project or only stored.", + "enum": ["applied", "stored"], + "type": "string" + }, + "updated_at": { + "type": "string" + } + }, + "required": ["allowed_cidrs", "entitlement", "status"], + "type": "object" + }, + "postgres_settings": { + "description": "Postgres parameter overrides. Empty when the project runs entirely on defaults.", + "properties": { + "checkpoint_timeout": { + "description": "Default unit: s", + "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$", + "type": "string" + }, + "cron_log_statement": { + "type": "boolean" + }, + "effective_cache_size": { + "type": "string" + }, + "hot_standby_feedback": { + "type": "boolean" + }, + "log_autovacuum_min_duration": { + "description": "Default unit: ms", + "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$", + "type": "string" + }, + "log_checkpoints": { + "type": "boolean" + }, + "log_connections": { + "type": "boolean" + }, + "log_disconnections": { + "type": "boolean" + }, + "log_duration": { + "type": "boolean" + }, + "log_lock_waits": { + "type": "boolean" + }, + "log_recovery_conflict_waits": { + "type": "boolean" + }, + "log_replication_commands": { + "type": "boolean" + }, + "log_startup_progress_interval": { + "description": "Default unit: ms", + "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$", + "type": "string" + }, + "log_temp_files": { + "type": "string" + }, + "logical_decoding_work_mem": { + "type": "string" + }, + "maintenance_work_mem": { + "type": "string" + }, + "max_connections": { + "maximum": 262143, + "minimum": 1, + "type": "integer" + }, + "max_locks_per_transaction": { + "maximum": 2147483640, + "minimum": 10, + "type": "integer" + }, + "max_logical_replication_workers": { + "maximum": 262143, + "minimum": 0, + "type": "integer" + }, + "max_parallel_maintenance_workers": { + "maximum": 1024, + "minimum": 0, + "type": "integer" + }, + "max_parallel_workers": { + "maximum": 1024, + "minimum": 0, + "type": "integer" + }, + "max_parallel_workers_per_gather": { + "maximum": 1024, + "minimum": 0, + "type": "integer" + }, + "max_replication_slots": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "max_slot_wal_keep_size": { + "type": "string" + }, + "max_standby_archive_delay": { + "type": "string" + }, + "max_standby_streaming_delay": { + "type": "string" + }, + "max_sync_workers_per_subscription": { + "maximum": 262143, + "minimum": 0, + "type": "integer" + }, + "max_wal_senders": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "max_wal_size": { + "type": "string" + }, + "max_worker_processes": { + "maximum": 262143, + "minimum": 0, + "type": "integer" + }, + "session_replication_role": { + "enum": ["local", "origin", "replica"], + "type": "string" + }, + "shared_buffers": { + "type": "string" + }, + "statement_timeout": { + "description": "Default unit: ms", + "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$", + "type": "string" + }, + "track_activity_query_size": { + "type": "string" + }, + "track_commit_timestamp": { + "type": "boolean" + }, + "wal_keep_size": { + "type": "string" + }, + "wal_sender_timeout": { + "description": "Default unit: ms", + "pattern": "^(-?[0-9]+(?:\\.[0-9]+)?)(us|ms|s|min|h|d)?$", + "type": "string" + }, + "work_mem": { + "type": "string" + } + }, + "type": "object" + }, + "ssl_enforced": { + "description": "Whether the database rejects plaintext connections", + "type": "boolean" + } + }, + "required": [ + "major_version", + "network_restrictions", + "postgres_settings", + "ssl_enforced" + ], + "type": "object" + }, + "pooler": { + "properties": { + "default_pool_size": { + "description": "Defaults to the pooler's size for the project's compute when not overridden.", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "ignore_startup_parameters": { + "type": "string" + }, + "max_client_conn": { + "description": "Defaults to the pooler's size for the project's compute when not overridden.", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "pool_mode": { + "enum": ["session", "statement", "transaction"], + "type": "string" + }, + "query_wait_timeout": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "reserve_pool_size": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "server_idle_timeout": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "server_lifetime": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + } + }, + "required": [ + "default_pool_size", + "ignore_startup_parameters", + "max_client_conn", + "pool_mode", + "query_wait_timeout", + "reserve_pool_size", + "server_idle_timeout", + "server_lifetime" + ], + "type": "object" + }, + "realtime": { + "properties": { + "connection_pool": { + "description": "Defaults to Realtime's pool size for the project's compute when not overridden.", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "max_bytes_per_second": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "max_channels_per_client": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "max_concurrent_users": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "max_events_per_second": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "max_joins_per_second": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "max_payload_size_in_kb": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "max_presence_events_per_second": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "postgres_changes_pool": { + "description": "If `null`, no override is stored and Realtime applies its own default.", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "nullable": true, + "type": "integer" + }, + "presence_enabled": { + "type": "boolean" + }, + "private_only": { + "type": "boolean" + }, + "suspend": { + "type": "boolean" + } + }, + "required": [ + "connection_pool", + "max_bytes_per_second", + "max_channels_per_client", + "max_concurrent_users", + "max_events_per_second", + "max_joins_per_second", + "max_payload_size_in_kb", + "max_presence_events_per_second", + "postgres_changes_pool", + "presence_enabled", + "private_only", + "suspend" + ], + "type": "object" + }, + "storage": { + "description": "Read from the storage service's admin API rather than the middleware DB, so unlike the rest of this resource it reflects the tenant's live config.", + "properties": { + "capabilities": { + "properties": { + "iceberg_catalog": { + "type": "boolean" + }, + "list_v2": { + "type": "boolean" + } + }, + "required": ["iceberg_catalog", "list_v2"], + "type": "object" + }, + "database_pool_mode": { + "type": "string" + }, + "features": { + "properties": { + "iceberg_catalog": { + "properties": { + "enabled": { + "type": "boolean" + }, + "max_catalogs": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "max_namespaces": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "max_tables": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + } + }, + "required": [ + "enabled", + "max_catalogs", + "max_namespaces", + "max_tables" + ], + "type": "object" + }, + "image_transformation": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": ["enabled"], + "type": "object" + }, + "purge_cache": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": ["enabled"], + "type": "object" + }, + "s3_protocol": { + "properties": { + "enabled": { + "type": "boolean" + } + }, + "required": ["enabled"], + "type": "object" + }, + "vector_buckets": { + "properties": { + "enabled": { + "type": "boolean" + }, + "max_buckets": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "max_indexes": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + } + }, + "required": [ + "enabled", + "max_buckets", + "max_indexes" + ], + "type": "object" + } + }, + "required": [ + "iceberg_catalog", + "image_transformation", + "purge_cache", + "s3_protocol", + "vector_buckets" + ], + "type": "object" + }, + "file_size_limit": { + "format": "int64", + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "migration_version": { + "type": "string" + }, + "upstream_target": { + "enum": ["canary", "main"], + "type": "string" + } + }, + "required": [ + "capabilities", + "database_pool_mode", + "features", + "file_size_limit", + "migration_version", + "upstream_target" + ], + "type": "object" + } + }, + "required": [ + "api", + "auth", + "database", + "pooler", + "realtime", + "storage" + ], + "type": "object" + }, + "id": { + "description": "Project ref.", + "type": "string" + }, + "type": { + "description": "Resource type.", + "enum": ["project_config"], + "type": "string" + } + }, + "required": ["attributes", "id", "type"], + "type": "object" + } + }, + "required": ["data"], + "type": "object" + }, + "V2ProjectCreationRateResponse": { + "properties": { + "data": { + "properties": { + "attributes": { + "additionalProperties": {}, + "properties": { + "active_project_count": { + "description": "Active projects already in the organization. On a paid plan the first one is absorbed by the organization's compute credits, so this decides whether the amount is zero.", + "example": 1, + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "amount": { + "description": "Authoritative rate a new project adds to the organization. Zero is authoritative and means the project can be created without an additional charge.", + "example": 10, + "type": "number" + }, + "currency": { + "description": "ISO 4217 currency of the amount.", + "example": "USD", + "type": "string" + }, + "organization_slug": { + "description": "Organization the charge lands on.", + "example": "my-org", + "type": "string" + }, + "plan_id": { + "description": "Plan that decided the amount.", + "example": "pro", + "type": "string" + }, + "recurrence": { + "description": "Interval the amount recurs at.", + "enum": ["hourly", "monthly"], + "example": "monthly", + "type": "string" + } + }, + "required": [ + "active_project_count", + "amount", + "currency", + "organization_slug", + "plan_id", + "recurrence" + ], + "type": "object" + }, + "type": { + "description": "Resource type.", + "enum": ["project_creation_rate"], + "type": "string" + } + }, + "required": ["attributes", "type"], + "type": "object" + } + }, + "required": ["data"], + "type": "object" + }, + "V2TransferProjectBody": { + "properties": { + "data": { + "properties": { + "attributes": { + "properties": { + "target_organization_slug": { + "type": "string" + } + }, + "required": ["target_organization_slug"], + "type": "object" + }, + "type": { + "description": "Resource type.", + "enum": ["project_transfer_input"], + "type": "string" + } + }, + "required": ["attributes", "type"], + "type": "object" + } + }, + "required": ["data"], + "type": "object" + }, + "V2WorkerResponse": { + "properties": { + "data": { + "properties": { + "attributes": { + "properties": { + "build_state": { + "enum": ["active", "building", "failed"], + "type": "string" + }, + "deleting": { + "type": "boolean" + }, + "image_version": { + "type": "string" + }, + "instances": { + "properties": { + "declared": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "live": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "ready": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "stale": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + } + }, + "required": ["declared", "live", "ready", "stale"], + "type": "object" + }, + "instances_error": { + "type": "string" + }, + "secret_generation": { + "type": "string" + }, + "spec": { + "properties": { + "exposure": { + "example": "public", + "type": "string" + }, + "instances": { + "example": 1, + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "runtime": { + "example": "node", + "type": "string" + }, + "size": { + "example": "2gb-1vcpu", + "type": "string" + } + }, + "required": ["exposure", "instances", "size"], + "type": "object" + }, + "state_reason": { + "type": "string" + } + }, + "required": ["build_state", "secret_generation", "spec"], + "type": "object" + }, + "id": { + "description": "Worker name.", + "example": "hello-world", + "type": "string" + }, + "type": { + "description": "Resource type.", + "enum": ["project_worker"], + "type": "string" + } + }, + "required": ["attributes", "id", "type"], + "type": "object" + } + }, + "required": ["data"], + "type": "object" + }, + "V2WorkerUploadResponse": { + "properties": { + "data": { + "properties": { + "attributes": { + "properties": { + "expires_at": { + "description": "When the slot stops accepting the upload.", + "type": "string" + }, + "method": { + "example": "PUT", + "type": "string" + }, + "url": { + "description": "Presigned destination for the `.tar.gz` build context.", + "type": "string" + } + }, + "required": ["expires_at", "method", "url"], + "type": "object" + }, + "id": { + "description": "Upload id to pass to the deploy endpoint as `context_upload_id`.", + "example": "cafe0000000000000000000000000000", + "type": "string" + }, + "type": { + "description": "Resource type.", + "enum": ["project_worker_upload"], + "type": "string" + } + }, + "required": ["attributes", "id", "type"], + "type": "object" + } + }, + "required": ["data"], + "type": "object" + } + }, + "securitySchemes": { + "bearer": { + "bearerFormat": "JWT", + "scheme": "bearer", + "type": "http" + } + } + }, + "info": { + "contact": {}, + "description": "Supabase API generated from the OpenAPI specification.
Visit [https://supabase.com/docs](https://supabase.com/docs) for a complete documentation.", + "title": "Supabase API (v2)", + "version": "1.0.0" + }, + "openapi": "3.0.0", + "paths": { + "/v2/organizations/{slug}/integrations/github/connections": { + "get": { + "description": "Returns a cursor-paginated list of the GitHub connections of the organization's projects.\n\nUse `page[after]` and `page[before]` to navigate pages and `page[size]` to control the page size.\nPaging walks the organization projects, so a page holds at most `page[size]` connections and can hold fewer (or none) when some of its projects are not connected.\nFollow `links.next` until it is `null` rather than stopping on a short page.\n\nUse `filter[project_ref]` to narrow the list down to a single project.", + "operationId": "v2-list-organization-github-connections", + "parameters": [ + { + "in": "query", + "name": "filter", + "required": false, + "schema": { + "properties": { + "project_ref": { + "description": "Project ref", + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + } + }, + "type": "object" + }, + "style": "deepObject" + }, + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "properties": { + "after": { + "description": "Project ref", + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + }, + "before": { + "description": "Project ref", + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + }, + "size": { + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "style": "deepObject" + }, + { + "description": "Organization slug", + "in": "path", + "name": "slug", + "required": true, + "schema": { + "example": "tsrqponmlkjihgfedcba", + "pattern": "^[\\w-]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ListGitHubConnectionsResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "List GitHub connections of an organization", + "tags": ["Organizations"], + "x-badges": [ + { + "name": "OAuth scope: projects:read", + "position": "after" + } + ], + "x-endpoint-owners": ["control-plane", "dev-workflows"], + "x-fga-permissions": [["organization_projects_read"]], + "x-oauth-scope": "projects:read" + } + }, + "/v2/organizations/{slug}/members": { + "get": { + "description": "Returns a cursor-paginated list of organization members including their roles and project-scoped permissions.", + "operationId": "v2-list-organization-members", + "parameters": [ + { + "in": "query", + "name": "filter", + "required": false, + "schema": { + "properties": { + "primary_email": { + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$", + "type": "string" + }, + "username": { + "type": "string" + } + }, + "type": "object" + }, + "style": "deepObject" + }, + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "properties": { + "after": { + "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)$", + "type": "string" + }, + "before": { + "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)$", + "type": "string" + }, + "size": { + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "style": "deepObject" + }, + { + "description": "Organization slug", + "in": "path", + "name": "slug", + "required": true, + "schema": { + "example": "tsrqponmlkjihgfedcba", + "pattern": "^[\\w-]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ListMembersResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "List members of an organization", + "tags": ["Organizations"], + "x-badges": [ + { + "name": "OAuth scope: organizations:read", + "position": "after" + } + ], + "x-endpoint-owners": ["control-plane"], + "x-fga-permissions": [["members_read"]], + "x-oauth-scope": "organizations:read" + } + }, + "/v2/organizations/{slug}/members/invitations": { + "delete": { + "description": "Bulk delete member invitations for an organization by email address.", + "operationId": "v2-delete-organization-invitations", + "parameters": [ + { + "description": "Organization slug", + "in": "path", + "name": "slug", + "required": true, + "schema": { + "example": "tsrqponmlkjihgfedcba", + "pattern": "^[\\w-]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2DeleteInvitationsRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2DeleteInvitationsResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "This feature requires the Enterprise organization plan." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Deletes organization invitations by email", + "tags": ["Organizations Members Invitations"], + "x-allowed-plans": ["Enterprise"], + "x-badges": [ + { + "name": "OAuth scope: organizations:write", + "position": "after" + }, + { + "name": "Only available on Enterprise", + "position": "before" + } + ], + "x-endpoint-owners": ["control-plane"], + "x-fga-permissions": [["members_write"]], + "x-oauth-scope": "organizations:write" + }, + "post": { + "description": "Creates member invitations for an organization. Each invitation can have different role and project scope settings.", + "operationId": "v2-create-organization-invitations", + "parameters": [ + { + "description": "Organization slug", + "in": "path", + "name": "slug", + "required": true, + "schema": { + "example": "tsrqponmlkjihgfedcba", + "pattern": "^[\\w-]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2CreateInvitationsRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2CreateInvitationsResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "This feature requires the Enterprise organization plan." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Creates organization invitations", + "tags": ["Organizations Members Invitations"], + "x-allowed-plans": ["Enterprise"], + "x-badges": [ + { + "name": "OAuth scope: organizations:write", + "position": "after" + }, + { + "name": "Only available on Enterprise", + "position": "before" + } + ], + "x-endpoint-owners": ["control-plane"], + "x-fga-permissions": [["members_write"]], + "x-oauth-scope": "organizations:write" + } + }, + "/v2/organizations/{slug}/members/{user_id}/roles": { + "patch": { + "description": "Assigns an org-wide role when projects is omitted, or creates a project-scoped assignment when projects is provided. Uses an org-level role template id from GET /v2/organizations/{slug}/roles. Stale role assignments are automatically cleaned up: if a role no longer has any projects, it is deleted; overlapping project assignments in other roles are automatically removed to avoid duplication.", + "operationId": "v2-assign-organization-member-role", + "parameters": [ + { + "description": "Organization slug", + "in": "path", + "name": "slug", + "required": true, + "schema": { + "example": "tsrqponmlkjihgfedcba", + "pattern": "^[\\w-]+$", + "type": "string" + } + }, + { + "in": "path", + "name": "user_id", + "required": true, + "schema": { + "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)$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2AssignOrganizationMemberRoleRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/OrganizationMemberRoleResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "This feature requires the Enterprise organization plan." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Failed to assign organization member role" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Assign or change an organization member role", + "tags": ["Organizations"], + "x-allowed-plans": ["Enterprise"], + "x-badges": [ + { + "name": "Only available on Enterprise", + "position": "before" + } + ], + "x-endpoint-owners": ["control-plane"], + "x-fga-permissions": [["organization_admin_write"]] + } + }, + "/v2/organizations/{slug}/project-creation-rate": { + "get": { + "description": "Returns the authoritative rate a new project adds to the organization, together with the context that decided it. A zero amount is authoritative and means the project can be created without an additional charge.", + "operationId": "v2-get-project-creation-rate", + "parameters": [ + { + "description": "Organization slug", + "in": "path", + "name": "slug", + "required": true, + "schema": { + "example": "tsrqponmlkjihgfedcba", + "pattern": "^[\\w-]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ProjectCreationRateResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Organization not found" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Get the rate for creating a project in an organization", + "tags": ["Organizations"], + "x-badges": [ + { + "name": "OAuth scope: projects:read", + "position": "after" + } + ], + "x-endpoint-owners": ["billing"], + "x-fga-permissions": [["organization_projects_read"]], + "x-oauth-scope": "projects:read" + } + }, + "/v2/organizations/{slug}/projects": { + "get": { + "description": "Returns a cursor-paginated list of projects for the specified organization, including their databases.\n\nUse `page[after]` and `page[before]` to navigate pages and `page[size]` to control the number of projects returned per page.", + "operationId": "v2-list-organization-projects", + "parameters": [ + { + "in": "query", + "name": "page", + "required": false, + "schema": { + "properties": { + "after": { + "minLength": 1, + "type": "string" + }, + "before": { + "minLength": 1, + "type": "string" + }, + "size": { + "maximum": 100, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "style": "deepObject" + }, + { + "description": "Case-insensitive substring match on the project name.", + "in": "query", + "name": "search", + "required": false, + "schema": { + "minLength": 1, + "type": "string" + } + }, + { + "description": "Organization slug", + "in": "path", + "name": "slug", + "required": true, + "schema": { + "example": "tsrqponmlkjihgfedcba", + "pattern": "^[\\w-]+$", + "type": "string" + } + }, + { + "description": "Sort order by creation time: `inserted_at` (oldest first) or `-inserted_at` (newest first). Defaults to `inserted_at`.", + "in": "query", + "name": "sort", + "required": false, + "schema": { + "enum": ["-inserted_at", "inserted_at"], + "example": "-inserted_at", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ListProjectsResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "List projects of an organization", + "tags": ["Organizations"], + "x-badges": [ + { + "name": "OAuth scope: projects:read", + "position": "after" + } + ], + "x-endpoint-owners": ["control-plane"], + "x-fga-permissions": [["organization_projects_read"]], + "x-oauth-scope": "projects:read" + } + }, + "/v2/organizations/{slug}/roles": { + "get": { + "description": "Returns a list of org-level roles for the organization.", + "operationId": "v2-list-organization-roles", + "parameters": [ + { + "description": "Organization slug", + "in": "path", + "name": "slug", + "required": true, + "schema": { + "example": "tsrqponmlkjihgfedcba", + "pattern": "^[\\w-]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ListRolesResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "List roles of an organization", + "tags": ["Organizations"], + "x-badges": [ + { + "name": "OAuth scope: organizations:read", + "position": "after" + } + ], + "x-endpoint-owners": ["control-plane"], + "x-fga-permissions": [["members_read"]], + "x-oauth-scope": "organizations:read" + } + }, + "/v2/projects/{ref}/analytics/log-drains": { + "get": { + "operationId": "v2-list-log-drains", + "parameters": [ + { + "description": "Project ref", + "in": "path", + "name": "ref", + "required": true, + "schema": { + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListLogDrainsResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Failed to fetch log drains" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "List project log drains", + "tags": ["Analytics"], + "x-badges": [ + { + "name": "OAuth scope: analytics_config:read", + "position": "after" + } + ], + "x-endpoint-owners": ["analytics"], + "x-fga-permissions": [["analytics_config_read"]], + "x-oauth-scope": "analytics_config:read" + }, + "post": { + "operationId": "v2-create-log-drain", + "parameters": [ + { + "description": "Project ref", + "in": "path", + "name": "ref", + "required": true, + "schema": { + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateLogDrainRequestOpenApi" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogDrainResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "This feature requires the Pro, Team, or Enterprise organization plan." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Failed to create a log drain" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Create a log drain for a project", + "tags": ["Analytics"], + "x-allowed-plans": ["Enterprise", "Pro", "Team"], + "x-badges": [ + { + "name": "Only available on Pro, Team, Enterprise", + "position": "before" + }, + { + "name": "OAuth scope: analytics_config:write", + "position": "after" + } + ], + "x-endpoint-owners": ["analytics"], + "x-fga-permissions": [["analytics_config_write"]], + "x-oauth-scope": "analytics_config:write" + } + }, + "/v2/projects/{ref}/analytics/log-drains/{id}": { + "delete": { + "operationId": "v2-delete-log-drain", + "parameters": [ + { + "description": "Log drains identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "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)$", + "type": "string" + } + }, + { + "description": "Project ref", + "in": "path", + "name": "ref", + "required": true, + "schema": { + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Failed to delete a log drain" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Delete a project log drain", + "tags": ["Analytics"], + "x-badges": [ + { + "name": "OAuth scope: analytics_config:write", + "position": "after" + } + ], + "x-endpoint-owners": ["analytics"], + "x-fga-permissions": [["analytics_config_write"]], + "x-oauth-scope": "analytics_config:write" + }, + "put": { + "operationId": "v2-update-log-drain", + "parameters": [ + { + "description": "Log drains identifier", + "in": "path", + "name": "id", + "required": true, + "schema": { + "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)$", + "type": "string" + } + }, + { + "description": "Project ref", + "in": "path", + "name": "ref", + "required": true, + "schema": { + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateLogDrainRequestOpenApi" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LogDrainResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Failed to update log drain" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Update a project log drain", + "tags": ["Analytics"], + "x-badges": [ + { + "name": "OAuth scope: analytics_config:write", + "position": "after" + } + ], + "x-endpoint-owners": ["analytics"], + "x-fga-permissions": [["analytics_config_write"]], + "x-oauth-scope": "analytics_config:write" + } + }, + "/v2/projects/{ref}/branch-creation-rate": { + "get": { + "description": "Returns the authoritative rate a new branch of this project adds to its organization, together with the context that decided it. A zero amount is authoritative and means the branch can be created without an additional charge.", + "operationId": "v2-get-branch-creation-rate", + "parameters": [ + { + "description": "Project ref", + "in": "path", + "name": "ref", + "required": true, + "schema": { + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2BranchCreationRateResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Project not found" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Get the rate for creating a branch of a project", + "tags": ["Environments"], + "x-badges": [ + { + "name": "OAuth scope: environment:read", + "position": "after" + } + ], + "x-endpoint-owners": ["billing"], + "x-fga-permissions": [["branching_development_read"]], + "x-oauth-scope": "environment:read" + } + }, + "/v2/projects/{ref}/config": { + "get": { + "description": "Returns the project's database, pooler, Auth, Data API, Realtime and Storage configuration — the same configuration a branch inherits from its base project. Each is the effective config, so a setting the project has never overridden is reported at its platform default rather than as null. Auth secrets are returned as an HMAC of their value. `storage` is read live from the storage service; the rest come from this platform's own records.", + "operationId": "v2-get-project-config", + "parameters": [ + { + "description": "Project ref", + "in": "path", + "name": "ref", + "required": true, + "schema": { + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ProjectConfigResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "[Alpha] Get a project's service configuration", + "tags": ["Projects"], + "x-endpoint-owners": ["control-plane", "infra"], + "x-fga-permissions": [ + [ + "auth_config_read", + "data_api_config_read", + "database_config_read", + "database_network_restrictions_read", + "database_read", + "database_ssl_config_read", + "realtime_config_read", + "storage_config_read" + ] + ] + } + }, + "/v2/projects/{ref}/private-link/associations": { + "get": { + "operationId": "v2-list-private-link-associations", + "parameters": [ + { + "description": "Project ref", + "in": "path", + "name": "ref", + "required": true, + "schema": { + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ListPrivateLinkAssociationsResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Failed to retrieve AWS accounts for project" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "List AWS accounts attached to the project PrivateLink share", + "tags": ["Projects"], + "x-endpoint-owners": ["control-plane", "platform-networking"], + "x-fga-permissions": [["project_admin_read"]] + }, + "post": { + "description": "Adds an AWS account to the project's PrivateLink configuration and schedules the AWS resources to be created.", + "operationId": "v2-create-private-link-association", + "parameters": [ + { + "description": "Project ref", + "in": "path", + "name": "ref", + "required": true, + "schema": { + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2CreatePrivateLinkAssociationRequest" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2PrivateLinkAssociationResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "402": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "This feature requires the Team, or Enterprise organization plan." + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Failed to add AWS account to PrivateLink share" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Add an AWS account to the project PrivateLink share", + "tags": ["Projects"], + "x-allowed-plans": ["Enterprise", "Team"], + "x-badges": [ + { + "name": "Only available on Team, Enterprise", + "position": "before" + } + ], + "x-endpoint-owners": ["control-plane", "platform-networking"], + "x-fga-permissions": [["project_admin_write"]] + } + }, + "/v2/projects/{ref}/private-link/associations/aws-account/{aws_account_id}": { + "delete": { + "description": "Removes an AWS account from the project's PrivateLink configuration (targeting the primary database). Cleans up the associated AWS resources.", + "operationId": "v2-delete-private-link-association", + "parameters": [ + { + "description": "AWS account ID used in PrivateLink association", + "in": "path", + "name": "aws_account_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Project ref", + "in": "path", + "name": "ref", + "required": true, + "schema": { + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Failed to remove AWS account from PrivateLink share" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Remove an AWS account from the project PrivateLink share", + "tags": ["Projects"], + "x-endpoint-owners": ["control-plane", "platform-networking"], + "x-fga-permissions": [["project_admin_write"]] + } + }, + "/v2/projects/{ref}/private-link/associations/aws-account/{aws_account_id}/database/{database_identifier}": { + "delete": { + "description": "Removes an AWS account from the project's PrivateLink configuration for the given read replica. Cleans up the associated AWS resources.", + "operationId": "v2-delete-private-link-association-for-database", + "parameters": [ + { + "description": "AWS account ID used in PrivateLink association", + "in": "path", + "name": "aws_account_id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Identifier of the read replica this PrivateLink association targets", + "in": "path", + "name": "database_identifier", + "required": true, + "schema": { + "type": "string" + } + }, + { + "description": "Project ref", + "in": "path", + "name": "ref", + "required": true, + "schema": { + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Failed to remove AWS account from PrivateLink share" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Remove an AWS account from a specific database PrivateLink share", + "tags": ["Projects"], + "x-endpoint-owners": ["control-plane", "platform-networking"], + "x-fga-permissions": [["project_admin_write"]] + } + }, + "/v2/projects/{ref}/transfers": { + "post": { + "operationId": "v2-transfer-a-project", + "parameters": [ + { + "description": "Project ref", + "in": "path", + "name": "ref", + "required": true, + "schema": { + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2TransferProjectBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Transfers a project to a different organization", + "tags": ["Projects"], + "x-endpoint-owners": ["control-plane"], + "x-fga-permissions": [["organization_admin_write"]] + } + }, + "/v2/projects/{ref}/transfers/previews": { + "post": { + "operationId": "v2-preview-a-project-transfer", + "parameters": [ + { + "description": "Project ref", + "in": "path", + "name": "ref", + "required": true, + "schema": { + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2TransferProjectBody" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2PreviewProjectTransferResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "Previews transferring a project to a different organizations, shows eligibility and impact", + "tags": ["Projects"], + "x-endpoint-owners": ["control-plane"], + "x-fga-permissions": [["project_admin_read"]] + } + }, + "/v2/projects/{ref}/workers": { + "get": { + "description": "Returns all workers you've previously deployed to the specified project.", + "operationId": "v2-list-all-workers", + "parameters": [ + { + "description": "Project ref", + "in": "path", + "name": "ref", + "required": true, + "schema": { + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2ListWorkersResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "[Alpha] List all workers", + "tags": ["Workers"], + "x-badges": [ + { + "name": "OAuth scope: edge_functions:read", + "position": "after" + } + ], + "x-endpoint-owners": ["functions"], + "x-fga-permissions": [["workers_read"]], + "x-oauth-scope": "edge_functions:read" + } + }, + "/v2/projects/{ref}/workers/{name}": { + "delete": { + "description": "Tombstones the worker. Its instances and image are torn down asynchronously.", + "operationId": "v2-delete-a-worker", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "example": "hello-world", + "pattern": "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$", + "type": "string" + } + }, + { + "description": "Project ref", + "in": "path", + "name": "ref", + "required": true, + "schema": { + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "[Alpha] Delete a worker", + "tags": ["Workers"], + "x-badges": [ + { + "name": "OAuth scope: edge_functions:write", + "position": "after" + } + ], + "x-endpoint-owners": ["functions"], + "x-fga-permissions": [["workers_write"]], + "x-oauth-scope": "edge_functions:write" + }, + "get": { + "description": "Returns a worker along with its instance tally. Poll this after a deploy until `build_state` leaves `building`.", + "operationId": "v2-get-a-worker", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "example": "hello-world", + "pattern": "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$", + "type": "string" + } + }, + { + "description": "Project ref", + "in": "path", + "name": "ref", + "required": true, + "schema": { + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2WorkerResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "[Alpha] Retrieve a worker", + "tags": ["Workers"], + "x-badges": [ + { + "name": "OAuth scope: edge_functions:read", + "position": "after" + } + ], + "x-endpoint-owners": ["functions"], + "x-fga-permissions": [["workers_read"]], + "x-oauth-scope": "edge_functions:read" + } + }, + "/v2/projects/{ref}/workers/{name}/deploy": { + "post": { + "description": "Creates the worker if it does not exist, building from a context staged through the uploads endpoint. The build runs asynchronously: this answers 202 and the worker reaches `build_state` `active` or `failed` later.", + "operationId": "v2-deploy-a-worker", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "example": "hello-world", + "pattern": "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$", + "type": "string" + } + }, + { + "description": "Project ref", + "in": "path", + "name": "ref", + "required": true, + "schema": { + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2DeployWorkerRequest" + } + } + }, + "required": true + }, + "responses": { + "202": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2WorkerResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "[Alpha] Deploy a worker", + "tags": ["Workers"], + "x-badges": [ + { + "name": "OAuth scope: edge_functions:write", + "position": "after" + } + ], + "x-endpoint-owners": ["functions"], + "x-fga-permissions": [["workers_write"]], + "x-oauth-scope": "edge_functions:write" + } + }, + "/v2/projects/{ref}/workers/{name}/uploads": { + "post": { + "description": "PUT the `.tar.gz` build context to the returned `url` before `expires_at`, then deploy with the upload id as `context_upload_id`. The bytes go straight to storage — no management API request carries them.", + "operationId": "v2-create-worker-upload", + "parameters": [ + { + "in": "path", + "name": "name", + "required": true, + "schema": { + "example": "hello-world", + "pattern": "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$", + "type": "string" + } + }, + { + "description": "Project ref", + "in": "path", + "name": "ref", + "required": true, + "schema": { + "example": "abcdefghijklmnopqrst", + "maxLength": 20, + "minLength": 20, + "pattern": "^[a-z]+$", + "type": "string" + } + } + ], + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2WorkerUploadResponse" + } + } + }, + "description": "" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Unauthorized" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Forbidden action" + }, + "429": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponseBody" + } + } + }, + "description": "Rate limit exceeded" + } + }, + "security": [ + { + "bearer": [] + } + ], + "summary": "[Alpha] Mint a presigned slot for a build-context upload", + "tags": ["Workers"], + "x-badges": [ + { + "name": "OAuth scope: edge_functions:write", + "position": "after" + } + ], + "x-endpoint-owners": ["functions"], + "x-fga-permissions": [["workers_write"]], + "x-oauth-scope": "edge_functions:write" + } + } + }, + "servers": [], + "tags": [] +} diff --git a/packages/mcp-server-supabase/src/management-api/index.ts b/packages/mcp-server-supabase/src/management-api/index.ts index 74f568b3..f928a9fe 100644 --- a/packages/mcp-server-supabase/src/management-api/index.ts +++ b/packages/mcp-server-supabase/src/management-api/index.ts @@ -9,14 +9,25 @@ import type { SuccessResponse, } from 'openapi-typescript-helpers'; import { z } from 'zod/v4'; -import type { paths } from './types.js'; +import type { paths as v1Paths } from './types.js'; +import type { paths as v2Paths } from './v2-types.js'; + +/** + * Every Management API version this package speaks, in one path map. + * + * The versions are separate generated modules with disjoint path prefixes, so + * one client covers both and a call site names the version in the path it + * requests. New public endpoints start at v2 while every existing caller keeps + * the v1 contract it was written against. + */ +export type ManagementApiPaths = v1Paths & v2Paths; export function createManagementApiClient( baseUrl: string, accessToken: string, headers: Record = {} ) { - return createClient({ + return createClient({ baseUrl, headers: { Authorization: `Bearer ${accessToken}`, @@ -25,7 +36,7 @@ export function createManagementApiClient( }); } -export type ManagementApiClient = Client; +export type ManagementApiClient = Client; export type SuccessResponseType< T extends Record, diff --git a/packages/mcp-server-supabase/src/management-api/v2-types.ts b/packages/mcp-server-supabase/src/management-api/v2-types.ts new file mode 100644 index 00000000..46703c1b --- /dev/null +++ b/packages/mcp-server-supabase/src/management-api/v2-types.ts @@ -0,0 +1,3183 @@ +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ + +export interface paths { + '/v2/organizations/{slug}/integrations/github/connections': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List GitHub connections of an organization + * @description Returns a cursor-paginated list of the GitHub connections of the organization's projects. + * + * Use `page[after]` and `page[before]` to navigate pages and `page[size]` to control the page size. + * Paging walks the organization projects, so a page holds at most `page[size]` connections and can hold fewer (or none) when some of its projects are not connected. + * Follow `links.next` until it is `null` rather than stopping on a short page. + * + * Use `filter[project_ref]` to narrow the list down to a single project. + */ + get: operations['v2-list-organization-github-connections']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/organizations/{slug}/members': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List members of an organization + * @description Returns a cursor-paginated list of organization members including their roles and project-scoped permissions. + */ + get: operations['v2-list-organization-members']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/organizations/{slug}/members/invitations': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Creates organization invitations + * @description Creates member invitations for an organization. Each invitation can have different role and project scope settings. + */ + post: operations['v2-create-organization-invitations']; + /** + * Deletes organization invitations by email + * @description Bulk delete member invitations for an organization by email address. + */ + delete: operations['v2-delete-organization-invitations']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/organizations/{slug}/members/{user_id}/roles': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Assign or change an organization member role + * @description Assigns an org-wide role when projects is omitted, or creates a project-scoped assignment when projects is provided. Uses an org-level role template id from GET /v2/organizations/{slug}/roles. Stale role assignments are automatically cleaned up: if a role no longer has any projects, it is deleted; overlapping project assignments in other roles are automatically removed to avoid duplication. + */ + patch: operations['v2-assign-organization-member-role']; + trace?: never; + }; + '/v2/organizations/{slug}/project-creation-rate': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the rate for creating a project in an organization + * @description Returns the authoritative rate a new project adds to the organization, together with the context that decided it. A zero amount is authoritative and means the project can be created without an additional charge. + */ + get: operations['v2-get-project-creation-rate']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/organizations/{slug}/projects': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List projects of an organization + * @description Returns a cursor-paginated list of projects for the specified organization, including their databases. + * + * Use `page[after]` and `page[before]` to navigate pages and `page[size]` to control the number of projects returned per page. + */ + get: operations['v2-list-organization-projects']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/organizations/{slug}/roles': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List roles of an organization + * @description Returns a list of org-level roles for the organization. + */ + get: operations['v2-list-organization-roles']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/projects/{ref}/analytics/log-drains': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List project log drains */ + get: operations['v2-list-log-drains']; + put?: never; + /** Create a log drain for a project */ + post: operations['v2-create-log-drain']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/projects/{ref}/analytics/log-drains/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Update a project log drain */ + put: operations['v2-update-log-drain']; + post?: never; + /** Delete a project log drain */ + delete: operations['v2-delete-log-drain']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/projects/{ref}/branch-creation-rate': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get the rate for creating a branch of a project + * @description Returns the authoritative rate a new branch of this project adds to its organization, together with the context that decided it. A zero amount is authoritative and means the branch can be created without an additional charge. + */ + get: operations['v2-get-branch-creation-rate']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/projects/{ref}/config': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * [Alpha] Get a project's service configuration + * @description Returns the project's database, pooler, Auth, Data API, Realtime and Storage configuration — the same configuration a branch inherits from its base project. Each is the effective config, so a setting the project has never overridden is reported at its platform default rather than as null. Auth secrets are returned as an HMAC of their value. `storage` is read live from the storage service; the rest come from this platform's own records. + */ + get: operations['v2-get-project-config']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/projects/{ref}/private-link/associations': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List AWS accounts attached to the project PrivateLink share */ + get: operations['v2-list-private-link-associations']; + put?: never; + /** + * Add an AWS account to the project PrivateLink share + * @description Adds an AWS account to the project's PrivateLink configuration and schedules the AWS resources to be created. + */ + post: operations['v2-create-private-link-association']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/projects/{ref}/private-link/associations/aws-account/{aws_account_id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Remove an AWS account from the project PrivateLink share + * @description Removes an AWS account from the project's PrivateLink configuration (targeting the primary database). Cleans up the associated AWS resources. + */ + delete: operations['v2-delete-private-link-association']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/projects/{ref}/private-link/associations/aws-account/{aws_account_id}/database/{database_identifier}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Remove an AWS account from a specific database PrivateLink share + * @description Removes an AWS account from the project's PrivateLink configuration for the given read replica. Cleans up the associated AWS resources. + */ + delete: operations['v2-delete-private-link-association-for-database']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/projects/{ref}/transfers': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Transfers a project to a different organization */ + post: operations['v2-transfer-a-project']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/projects/{ref}/transfers/previews': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Previews transferring a project to a different organizations, shows eligibility and impact */ + post: operations['v2-preview-a-project-transfer']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/projects/{ref}/workers': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * [Alpha] List all workers + * @description Returns all workers you've previously deployed to the specified project. + */ + get: operations['v2-list-all-workers']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/projects/{ref}/workers/{name}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * [Alpha] Retrieve a worker + * @description Returns a worker along with its instance tally. Poll this after a deploy until `build_state` leaves `building`. + */ + get: operations['v2-get-a-worker']; + put?: never; + post?: never; + /** + * [Alpha] Delete a worker + * @description Tombstones the worker. Its instances and image are torn down asynchronously. + */ + delete: operations['v2-delete-a-worker']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/projects/{ref}/workers/{name}/deploy': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * [Alpha] Deploy a worker + * @description Creates the worker if it does not exist, building from a context staged through the uploads endpoint. The build runs asynchronously: this answers 202 and the worker reaches `build_state` `active` or `failed` later. + */ + post: operations['v2-deploy-a-worker']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/v2/projects/{ref}/workers/{name}/uploads': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * [Alpha] Mint a presigned slot for a build-context upload + * @description PUT the `.tar.gz` build context to the returned `url` before `expires_at`, then deploy with the upload id as `context_upload_id`. The bytes go straight to storage — no management API request carries them. + */ + post: operations['v2-create-worker-upload']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { + CreateLogDrainRequestOpenApi: { + data: { + attributes: { + /** @enum {string} */ + backend_type: + | 'axiom' + | 'bigquery' + | 'clickhouse' + | 'datadog' + | 'last9' + | 'loki' + | 'otlp' + | 'postgres' + | 's3' + | 'sentry' + | 'syslog' + | 'webhook'; + config: + | { + hostname?: string; + password?: string | null; + port?: number | null; + schema?: string; + url?: string | null; + username?: string | null; + } + | { + gzip?: boolean; + headers?: { + [key: string]: string; + }; + /** @enum {string} */ + http?: 'http1' | 'http2'; + url?: string; + } + | { + dataset_id?: string; + project_id?: string; + } + | { + api_key?: string; + region?: string; + } + | { + headers?: { + [key: string]: string; + }; + password?: string | null; + url?: string; + username?: string | null; + } + | { + dsn?: string; + } + | { + api_token?: string; + dataset_name?: string; + domain?: string; + } + | { + ca_cert?: string; + cipher_key?: string; + client_cert?: string; + client_key?: string; + host?: string; + port?: number; + structured_data?: string; + /** @default false */ + tls: boolean; + }; + description?: string; + name: string; + }; + /** + * @description Resource type. + * @enum {string} + */ + type: 'log_drain'; + }; + }; + ErrorResponseBody: { + error: components['schemas']['ErrorResponseBodyAPIErrorObject']; + }; + ErrorResponseBodyAPIErrorObject: { + code: string; + description?: string; + id?: string; + issues?: components['schemas']['ErrorResponseBodyAPIErrorObject'][]; + links?: { + [key: string]: { + describedby?: string; + href: string; + meta?: { + [key: string]: unknown; + }; + rel?: string; + title?: string; + type?: string; + }; + }; + message: string; + meta?: { + [key: string]: unknown; + }; + }; + ListLogDrainsResponse: { + data: { + attributes: { + /** @enum {string} */ + backend_type: + | 'axiom' + | 'bigquery' + | 'clickhouse' + | 'datadog' + | 'last9' + | 'loki' + | 'otlp' + | 'postgres' + | 's3' + | 'sentry' + | 'syslog' + | 'webhook'; + config: + | { + hostname?: string; + password?: string | null; + port?: number | null; + schema?: string; + url?: string | null; + username?: string | null; + } + | { + gzip?: boolean; + headers?: { + [key: string]: string; + }; + /** @enum {string} */ + http?: 'http1' | 'http2'; + url?: string; + } + | { + dataset_id?: string; + project_id?: string; + } + | { + api_key?: string; + region?: string; + } + | { + headers?: { + [key: string]: string; + }; + password?: string | null; + url?: string; + username?: string | null; + } + | { + dsn?: string; + } + | { + api_token?: string; + dataset_name?: string; + domain?: string; + } + | { + ca_cert?: string; + cipher_key?: string; + client_cert?: string; + client_key?: string; + host?: string; + port?: number; + structured_data?: string; + /** @default false */ + tls: boolean; + }; + description?: string; + name: string; + }; + id: string; + /** + * @description Resource type. + * @enum {string} + */ + type: 'log_drain'; + }[]; + }; + LogDrainResponse: { + data: { + attributes: { + /** @enum {string} */ + backend_type: + | 'axiom' + | 'bigquery' + | 'clickhouse' + | 'datadog' + | 'last9' + | 'loki' + | 'otlp' + | 'postgres' + | 's3' + | 'sentry' + | 'syslog' + | 'webhook'; + config: + | { + hostname?: string; + password?: string | null; + port?: number | null; + schema?: string; + url?: string | null; + username?: string | null; + } + | { + gzip?: boolean; + headers?: { + [key: string]: string; + }; + /** @enum {string} */ + http?: 'http1' | 'http2'; + url?: string; + } + | { + dataset_id?: string; + project_id?: string; + } + | { + api_key?: string; + region?: string; + } + | { + headers?: { + [key: string]: string; + }; + password?: string | null; + url?: string; + username?: string | null; + } + | { + dsn?: string; + } + | { + api_token?: string; + dataset_name?: string; + domain?: string; + } + | { + ca_cert?: string; + cipher_key?: string; + client_cert?: string; + client_key?: string; + host?: string; + port?: number; + structured_data?: string; + /** @default false */ + tls: boolean; + }; + description?: string; + name: string; + }; + id: string; + /** + * @description Resource type. + * @enum {string} + */ + type: 'log_drain'; + }; + }; + OrganizationMemberRoleResponse: { + data: { + attributes: { + /** + * @description Role name. For project-scoped assignments this is the base role name. + * @example developer + */ + name: string; + /** @description Project refs this role is scoped to. Empty array for org-level roles. */ + projects: { + name: string; + ref: string; + }[]; + /** + * @description Whether this role applies org-wide or is scoped to specific projects for the user. + * @enum {string} + */ + scope: 'organization' | 'project'; + }; + /** + * @description Resource type. + * @enum {string} + */ + type: 'organization_member_role'; + }; + }; + UpdateLogDrainRequestOpenApi: { + data: { + attributes: { + /** @enum {string} */ + backend_type: + | 'axiom' + | 'bigquery' + | 'clickhouse' + | 'datadog' + | 'last9' + | 'loki' + | 'otlp' + | 'postgres' + | 's3' + | 'sentry' + | 'syslog' + | 'webhook'; + config?: + | { + hostname?: string; + password?: string | null; + port?: number | null; + schema?: string; + url?: string | null; + username?: string | null; + } + | { + gzip?: boolean; + headers?: { + [key: string]: string; + }; + /** @enum {string} */ + http?: 'http1' | 'http2'; + url?: string; + } + | { + dataset_id?: string; + project_id?: string; + } + | { + api_key?: string; + region?: string; + } + | { + headers?: { + [key: string]: string; + }; + password?: string | null; + url?: string; + username?: string | null; + } + | { + dsn?: string; + } + | { + api_token?: string; + dataset_name?: string; + domain?: string; + } + | { + ca_cert?: string; + cipher_key?: string; + client_cert?: string; + client_key?: string; + host?: string; + port?: number; + structured_data?: string; + /** @default false */ + tls: boolean; + }; + description?: string; + name?: string; + }; + /** + * @description Resource type. + * @enum {string} + */ + type: 'log_drain'; + }; + }; + V2AssignOrganizationMemberRoleRequest: { + data: { + attributes: { + /** @description The projects to assign a project-scoped role for. If omitted, assigns an org-wide role. */ + projects?: { + /** + * @description Project ref + * @example abcjuqabhgwjjutfvtpa + */ + ref: string; + }[]; + /** + * @description Role name to assign. Must be one of: owner, administrator, developer, read-only. Must be on a Team or Enterprise plan to use the read-only role. + * @example developer + * @enum {string} + */ + role: 'administrator' | 'developer' | 'owner' | 'read-only'; + }; + /** + * @description Resource type. + * @enum {string} + */ + type: 'organization_member_role'; + }; + }; + V2BranchCreationRateResponse: { + data: { + attributes: { + /** + * @description Authoritative rate a new branch adds to the organization. Zero is authoritative and means the branch can be created without an additional charge. + * @example 0.01344 + */ + amount: number; + /** + * @description ISO 4217 currency of the amount. + * @example USD + */ + currency: string; + /** + * @description Organization the charge lands on. + * @example my-org + */ + organization_slug: string; + /** + * @description Project the branch would be created under. + * @example abcdefghijklmnopqrst + */ + parent_project_ref: string; + /** + * @description Plan that decided the amount. + * @example pro + */ + plan_id: string; + /** + * @description Interval the amount recurs at. + * @example hourly + * @enum {string} + */ + recurrence: 'hourly' | 'monthly'; + } & { + [key: string]: unknown; + }; + /** + * @description Resource type. + * @enum {string} + */ + type: 'branch_creation_rate'; + }; + }; + V2CreateInvitationsRequest: { + data: { + attributes: { + /** Format: email */ + email: string; + /** @description The projects to limit a user to. If omitted, user will have org-wide access with the provided role. */ + projects?: { + /** + * @description Project ref + * @example abcjuqabhgwjjutfvtpa + */ + ref: string; + }[]; + require_sso?: boolean; + /** + * @description Role name to assign. Must be on a Team or Enterprise plan to use the read-only role. + * @example developer + * @enum {string} + */ + role: 'administrator' | 'developer' | 'owner' | 'read-only'; + }; + /** + * @description Resource type. + * @enum {string} + */ + type: 'organization_invitation'; + }[]; + }; + V2CreateInvitationsResponse: { + data: { + attributes: { + /** Format: email */ + email: string; + }; + /** + * @description Resource type. + * @enum {string} + */ + type: 'organization_invitation'; + }[]; + error?: { + code: string; + description?: string; + id?: string; + issues?: { + code: string; + description?: string; + id?: string; + links?: { + [key: string]: { + describedby?: string; + href: string; + meta?: { + [key: string]: unknown; + }; + rel?: string; + title?: string; + type?: string; + }; + }; + message: string; + meta: { + /** Format: email */ + email: string; + }; + }[]; + links?: { + [key: string]: { + describedby?: string; + href: string; + meta?: { + [key: string]: unknown; + }; + rel?: string; + title?: string; + type?: string; + }; + }; + message: string; + meta?: { + [key: string]: unknown; + }; + }; + }; + V2CreatePrivateLinkAssociationRequest: { + data: { + attributes: { + /** @description Optional human-readable name for the AWS account. */ + account_name?: string; + /** @description The AWS account ID to add to the project PrivateLink share. */ + aws_account_id: string; + /** @description Identifier of the read replica this PrivateLink share should target. Omit to target the primary database. */ + database_identifier?: string; + }; + /** + * @description Resource type. + * @enum {string} + */ + type: 'private_link_association'; + }; + }; + V2DeleteInvitationsRequest: { + data: { + attributes: { + /** Format: email */ + email: string; + }; + /** + * @description Resource type. + * @enum {string} + */ + type: 'organization_invitation'; + }[]; + }; + V2DeleteInvitationsResponse: { + data: { + attributes: { + /** Format: email */ + email: string; + }; + /** + * @description Resource type. + * @enum {string} + */ + type: 'organization_invitation'; + }[]; + }; + V2DeployWorkerRequest: { + data: { + attributes: { + /** @description Id of a build context staged through the uploads endpoint. Required unless `runtime` is set. */ + context_upload_id?: string; + spec: { + /** @example public */ + exposure: string; + /** @example 1 */ + instances: number; + /** @example node */ + runtime?: string; + /** @example 2gb-1vcpu */ + size: string; + }; + }; + /** + * @description Resource type. + * @enum {string} + */ + type: 'project_worker'; + }; + }; + V2ListGitHubConnectionsResponse: { + data: { + attributes: { + /** @description Maximum number of preview branches */ + branch_limit: number; + /** @description When the connection was created */ + inserted_at: string; + /** @description GitHub App installation id */ + installation_id: number; + /** @description Whether a preview branch is created for every pull request */ + new_branch_per_pr: boolean; + /** @description The connected Supabase project */ + project: { + id: number; + name: string; + /** + * @description Project ref + * @example abcdefghijklmnopqrst + */ + ref: string; + }; + /** @description The connected GitHub repository */ + repository: { + id: number; + name: string; + }; + /** @description Whether branches are only created for changes under `supabase/` */ + supabase_changes_only: boolean; + /** @description When the connection was last updated */ + updated_at: string; + /** @description The user who created the connection, if still known */ + user: { + id: number; + primary_email: string | null; + username: string; + } | null; + /** @description Directory within the repository the project lives in */ + workdir: string; + }; + /** + * @description Connection id. + * @example 7 + */ + id: string; + /** + * @description Resource type. + * @enum {string} + */ + type: 'github_connection'; + }[]; + links: { + /** + * @description URL path to the first page if available. + * @example /v2/organizations/my-org/integrations/github/connections?page[size]=10 + */ + first?: string | null; + /** + * @description URL path to the last page if available. + * @example /v2/organizations/my-org/integrations/github/connections?page[size]=10&page[after]=019adf7d-4513-71ba-b264-21900edb4295 + */ + last?: string | null; + /** + * @description URL path to the next page. + * @example /v2/organizations/my-org/integrations/github/connections?page[size]=10&page[after]=019adf7d-4513-7062-b292-78b86cc470a4 + */ + next: string | null; + /** + * @description URL path to the previous page. + * @example /v2/organizations/my-org/integrations/github/connections?page[size]=10&page[before]=019adf7d-4513-74c5-bb9a-f1bc0f7a95d7 + */ + prev: string | null; + }; + }; + V2ListMembersResponse: { + data: { + attributes: { + /** @description Member's avatar URL */ + avatar_url: string | null; + /** @description Whether this member is a Single Sign-On user */ + is_sso_user: boolean; + /** @description Whether Multi-Factor Authentication is enabled for this member */ + mfa_enabled: boolean; + /** @description Member's primary email */ + primary_email: string | null; + /** @description Roles assigned to this member. Includes both org-level and project-scoped roles. */ + roles: { + /** + * @description Role name. For project-scoped roles this is the base role name. + * @example developer + */ + name: string; + /** @description Project refs this role is scoped to. Empty array for org-level roles. */ + projects: { + name: string; + ref: string; + }[]; + /** + * @description Whether this role applies org-wide or is scoped to specific projects for the user. + * @enum {string} + */ + scope: 'organization' | 'project'; + }[]; + /** @description Member's username */ + username: string | null; + }; + /** Format: uuid */ + id: string; + /** + * @description Resource type. + * @enum {string} + */ + type: 'organization_member'; + }[]; + links: { + /** + * @description URL path to the first page if available. + * @example /v2/organizations/my-org/members?page[size]=10 + */ + first?: string | null; + /** + * @description URL path to the last page if available. + * @example /v2/organizations/my-org/members?page[size]=10&page[after]=019adf7d-4513-71ba-b264-21900edb4295 + */ + last?: string | null; + /** + * @description URL path to the next page. + * @example /v2/organizations/my-org/members?page[size]=10&page[after]=019adf7d-4513-7062-b292-78b86cc470a4 + */ + next: string | null; + /** + * @description URL path to the previous page. + * @example /v2/organizations/my-org/members?page[size]=10&page[before]=019adf7d-4513-74c5-bb9a-f1bc0f7a95d7 + */ + prev: string | null; + }; + }; + V2ListPrivateLinkAssociationsResponse: { + data: { + attributes: { + /** @description Human-readable name for the AWS account. */ + account_name?: string; + /** @description The AWS account ID this PrivateLink share is associated with. */ + aws_account_id: string; + /** @description Identifier of the database this PrivateLink share targets - the project ref for the primary, or the read replica identifier. */ + database_identifier: string; + /** + * @description Whether this PrivateLink share targets the primary database or a read replica. + * @enum {string} + */ + database_type: 'PRIMARY' | 'READ_REPLICA'; + /** @description ARN of the AWS VPC Lattice resource configuration backing this PrivateLink share. */ + resource_access_manager_resource_config_arn?: string; + /** @description ID of the AWS VPC Lattice resource configuration backing this PrivateLink share. */ + resource_access_manager_resource_config_id?: string; + /** @description ARN of the AWS Resource Access Manager resource share for this association. */ + resource_access_manager_share_arn?: string; + /** + * Format: date-time + * @description The time and date at which the AWS Resource Share Association was requested from Supabase. `null` means that the association was not yet requested while the PrivateLink Association is pending. + */ + shared_at: string | null; + /** + * @description + * - `CREATING`: The PrivateLink resources and the Association are in the process of being created. The PrivateLink Share cannot be accepted yet. + * - `READY`: The PrivateLink resources have been created and the PrivateLink Share can be accepted for the duration of 12h after sharing. See `shared_at`. + * - `ASSOCIATION_REQUEST_EXPIRED`: The PrivateLink Share has not been accepted within the 12h time limit. This association can now be deleted. + * - `ASSOCIATION_ACCEPTED`: The PrivateLink Share was successfully accepted. + * - `CREATION_FAILED`: The PrivateLink resources failed to create. This likely means something went wrong on Supabase's and and support should be contacted. + * - `DELETING`: The PrivateLink resources and the Association are in the process of being deleted. The PrivateLink Share cannot be accepted yet. + * + * @enum {string} + */ + status: + | 'ASSOCIATION_ACCEPTED' + | 'ASSOCIATION_REQUEST_EXPIRED' + | 'CREATING' + | 'CREATION_FAILED' + | 'DELETING' + | 'READY'; + }; + id: string; + /** + * @description Resource type. + * @enum {string} + */ + type: 'private_link_association'; + }[]; + }; + V2ListProjectsResponse: { + data: { + attributes: { + /** @description Cloud provider hosting the project */ + cloud_provider: string; + /** @description The project's databases including compute and disk attributes. */ + databases: { + cloud_provider: string; + disk_last_modified_at?: string; + disk_throughput_mbps?: number; + /** @enum {string} */ + disk_type?: 'gp3' | 'io2'; + disk_volume_size_gb?: number; + identifier: string; + /** @enum {string} */ + infra_compute_size?: + | '12xlarge' + | '16xlarge' + | '24xlarge' + | '24xlarge_high_memory' + | '24xlarge_optimized_cpu' + | '24xlarge_optimized_memory' + | '2xlarge' + | '48xlarge' + | '48xlarge_high_memory' + | '48xlarge_optimized_cpu' + | '48xlarge_optimized_memory' + | '4xlarge' + | '8xlarge' + | 'large' + | 'medium' + | 'micro' + | 'nano' + | 'pico' + | 'small' + | 'xlarge'; + region: string | null; + /** @enum {string} */ + status: + | 'ACTIVE_HEALTHY' + | 'ACTIVE_UNHEALTHY' + | 'COMING_UP' + | 'GOING_DOWN' + | 'INIT_FAILED' + | 'INIT_READ_REPLICA' + | 'INIT_READ_REPLICA_FAILED' + | 'REMOVED' + | 'RESIZING' + | 'RESTARTING' + | 'RESTORING' + | 'UNKNOWN'; + /** @enum {string} */ + type: 'PRIMARY' | 'READ_REPLICA'; + }[]; + /** @description When the project was created */ + inserted_at: string; + /** @description Project name */ + name: string; + /** @description Region the project is hosted in */ + region: string; + /** + * @description Project status + * @enum {string} + */ + status: + | 'ACTIVE_HEALTHY' + | 'ACTIVE_UNHEALTHY' + | 'COMING_UP' + | 'GOING_DOWN' + | 'INACTIVE' + | 'INIT_FAILED' + | 'PAUSE_FAILED' + | 'PAUSING' + | 'REMOVED' + | 'RESIZING' + | 'RESTARTING' + | 'RESTORE_FAILED' + | 'RESTORING' + | 'UNKNOWN' + | 'UPGRADING'; + }; + /** + * @description Project ref + * @example abcdefghijklmnopqrst + */ + id: string; + /** + * @description Resource type. + * @enum {string} + */ + type: 'project'; + }[]; + links: { + /** + * @description URL path to the first page if available. + * @example /v2/organizations/my-org/projects?page[size]=10 + */ + first?: string | null; + /** + * @description URL path to the last page if available. + * @example /v2/organizations/my-org/projects?page[size]=10&page[after]=019adf7d-4513-71ba-b264-21900edb4295 + */ + last?: string | null; + /** + * @description URL path to the next page. + * @example /v2/organizations/my-org/projects?page[size]=10&page[after]=019adf7d-4513-7062-b292-78b86cc470a4 + */ + next: string | null; + /** + * @description URL path to the previous page. + * @example /v2/organizations/my-org/projects?page[size]=10&page[before]=019adf7d-4513-74c5-bb9a-f1bc0f7a95d7 + */ + prev: string | null; + }; + }; + V2ListRolesResponse: { + data: { + attributes: { + /** + * @description Role name. + * @example developer + */ + name: string; + }; + /** + * @description Resource type. + * @enum {string} + */ + type: 'organization_role'; + }[]; + }; + V2ListWorkersResponse: { + data: { + attributes: { + /** @enum {string} */ + build_state: 'active' | 'building' | 'failed'; + deleting?: boolean; + image_version?: string; + instances?: { + declared: number; + live: number; + ready: number; + stale: number; + }; + instances_error?: string; + secret_generation: string; + spec: { + /** @example public */ + exposure: string; + /** @example 1 */ + instances: number; + /** @example node */ + runtime?: string; + /** @example 2gb-1vcpu */ + size: string; + }; + state_reason?: string; + }; + /** + * @description Worker name. + * @example hello-world + */ + id: string; + /** + * @description Resource type. + * @enum {string} + */ + type: 'project_worker'; + }[]; + }; + V2PreviewProjectTransferResponse: { + data: { + attributes: { + errors: { + key: string; + message: string; + }[]; + info: { + key: string; + message: string; + }[]; + valid: boolean; + warnings: { + key: string; + message: string; + }[]; + }; + /** + * @description Resource type. + * @enum {string} + */ + type: 'project_transfer_result'; + }; + }; + V2PrivateLinkAssociationResponse: { + data: { + attributes: { + /** @description Human-readable name for the AWS account. */ + account_name?: string; + /** @description The AWS account ID this PrivateLink share is associated with. */ + aws_account_id: string; + /** @description Identifier of the database this PrivateLink share targets - the project ref for the primary, or the read replica identifier. */ + database_identifier: string; + /** + * @description Whether this PrivateLink share targets the primary database or a read replica. + * @enum {string} + */ + database_type: 'PRIMARY' | 'READ_REPLICA'; + /** @description ARN of the AWS VPC Lattice resource configuration backing this PrivateLink share. */ + resource_access_manager_resource_config_arn?: string; + /** @description ID of the AWS VPC Lattice resource configuration backing this PrivateLink share. */ + resource_access_manager_resource_config_id?: string; + /** @description ARN of the AWS Resource Access Manager resource share for this association. */ + resource_access_manager_share_arn?: string; + /** + * Format: date-time + * @description The time and date at which the AWS Resource Share Association was requested from Supabase. `null` means that the association was not yet requested while the PrivateLink Association is pending. + */ + shared_at: string | null; + /** + * @description + * - `CREATING`: The PrivateLink resources and the Association are in the process of being created. The PrivateLink Share cannot be accepted yet. + * - `READY`: The PrivateLink resources have been created and the PrivateLink Share can be accepted for the duration of 12h after sharing. See `shared_at`. + * - `ASSOCIATION_REQUEST_EXPIRED`: The PrivateLink Share has not been accepted within the 12h time limit. This association can now be deleted. + * - `ASSOCIATION_ACCEPTED`: The PrivateLink Share was successfully accepted. + * - `CREATION_FAILED`: The PrivateLink resources failed to create. This likely means something went wrong on Supabase's and and support should be contacted. + * - `DELETING`: The PrivateLink resources and the Association are in the process of being deleted. The PrivateLink Share cannot be accepted yet. + * + * @enum {string} + */ + status: + | 'ASSOCIATION_ACCEPTED' + | 'ASSOCIATION_REQUEST_EXPIRED' + | 'CREATING' + | 'CREATION_FAILED' + | 'DELETING' + | 'READY'; + }; + id: string; + /** + * @description Resource type. + * @enum {string} + */ + type: 'private_link_association'; + }; + }; + V2ProjectConfigResponse: { + data: { + attributes: { + api: { + db_extra_search_path: string; + /** @description If `null`, no pool size is written to the project's PostgREST config and PostgREST's own default applies. The platform does not pick a value here. */ + db_pool: number | null; + db_pool_acquisition_timeout: number; + /** @description Schemas exposed through the Data API */ + db_schema: string; + max_rows: number; + }; + /** @description Effective Auth config, keyed by lowercased GoTrue setting name and resolved through the `gotrue_config` view, so a setting the project has never overridden is reported at its platform default. Secrets are returned as an HMAC of their value, never in plaintext. */ + auth: { + [key: string]: unknown; + }; + database: { + /** @description The major Postgres version the database runs. `17` covers both Postgres 17 and Oriole on 17, since Oriole is a storage engine rather than a version. */ + major_version: number; + network_restrictions: { + allowed_cidrs: { + address: string; + /** @enum {string} */ + type: 'v4' | 'v6'; + }[]; + applied_at?: string; + /** @enum {string} */ + entitlement: 'allowed' | 'disallowed'; + /** + * @description Whether the allowlist below is applied to the project or only stored. + * @enum {string} + */ + status: 'applied' | 'stored'; + updated_at?: string; + }; + /** @description Postgres parameter overrides. Empty when the project runs entirely on defaults. */ + postgres_settings: { + /** @description Default unit: s */ + checkpoint_timeout?: string; + cron_log_statement?: boolean; + effective_cache_size?: string; + hot_standby_feedback?: boolean; + /** @description Default unit: ms */ + log_autovacuum_min_duration?: string; + log_checkpoints?: boolean; + log_connections?: boolean; + log_disconnections?: boolean; + log_duration?: boolean; + log_lock_waits?: boolean; + log_recovery_conflict_waits?: boolean; + log_replication_commands?: boolean; + /** @description Default unit: ms */ + log_startup_progress_interval?: string; + log_temp_files?: string; + logical_decoding_work_mem?: string; + maintenance_work_mem?: string; + max_connections?: number; + max_locks_per_transaction?: number; + max_logical_replication_workers?: number; + max_parallel_maintenance_workers?: number; + max_parallel_workers?: number; + max_parallel_workers_per_gather?: number; + max_replication_slots?: number; + max_slot_wal_keep_size?: string; + max_standby_archive_delay?: string; + max_standby_streaming_delay?: string; + max_sync_workers_per_subscription?: number; + max_wal_senders?: number; + max_wal_size?: string; + max_worker_processes?: number; + /** @enum {string} */ + session_replication_role?: 'local' | 'origin' | 'replica'; + shared_buffers?: string; + /** @description Default unit: ms */ + statement_timeout?: string; + track_activity_query_size?: string; + track_commit_timestamp?: boolean; + wal_keep_size?: string; + /** @description Default unit: ms */ + wal_sender_timeout?: string; + work_mem?: string; + }; + /** @description Whether the database rejects plaintext connections */ + ssl_enforced: boolean; + }; + pooler: { + /** @description Defaults to the pooler's size for the project's compute when not overridden. */ + default_pool_size: number; + ignore_startup_parameters: string; + /** @description Defaults to the pooler's size for the project's compute when not overridden. */ + max_client_conn: number; + /** @enum {string} */ + pool_mode: 'session' | 'statement' | 'transaction'; + query_wait_timeout: number; + reserve_pool_size: number; + server_idle_timeout: number; + server_lifetime: number; + }; + realtime: { + /** @description Defaults to Realtime's pool size for the project's compute when not overridden. */ + connection_pool: number; + max_bytes_per_second: number; + max_channels_per_client: number; + max_concurrent_users: number; + max_events_per_second: number; + max_joins_per_second: number; + max_payload_size_in_kb: number; + max_presence_events_per_second: number; + /** @description If `null`, no override is stored and Realtime applies its own default. */ + postgres_changes_pool: number | null; + presence_enabled: boolean; + private_only: boolean; + suspend: boolean; + }; + /** @description Read from the storage service's admin API rather than the middleware DB, so unlike the rest of this resource it reflects the tenant's live config. */ + storage: { + capabilities: { + iceberg_catalog: boolean; + list_v2: boolean; + }; + database_pool_mode: string; + features: { + iceberg_catalog: { + enabled: boolean; + max_catalogs: number; + max_namespaces: number; + max_tables: number; + }; + image_transformation: { + enabled: boolean; + }; + purge_cache: { + enabled: boolean; + }; + s3_protocol: { + enabled: boolean; + }; + vector_buckets: { + enabled: boolean; + max_buckets: number; + max_indexes: number; + }; + }; + /** Format: int64 */ + file_size_limit: number; + migration_version: string; + /** @enum {string} */ + upstream_target: 'canary' | 'main'; + }; + }; + /** @description Project ref. */ + id: string; + /** + * @description Resource type. + * @enum {string} + */ + type: 'project_config'; + }; + }; + V2ProjectCreationRateResponse: { + data: { + attributes: { + /** + * @description Active projects already in the organization. On a paid plan the first one is absorbed by the organization's compute credits, so this decides whether the amount is zero. + * @example 1 + */ + active_project_count: number; + /** + * @description Authoritative rate a new project adds to the organization. Zero is authoritative and means the project can be created without an additional charge. + * @example 10 + */ + amount: number; + /** + * @description ISO 4217 currency of the amount. + * @example USD + */ + currency: string; + /** + * @description Organization the charge lands on. + * @example my-org + */ + organization_slug: string; + /** + * @description Plan that decided the amount. + * @example pro + */ + plan_id: string; + /** + * @description Interval the amount recurs at. + * @example monthly + * @enum {string} + */ + recurrence: 'hourly' | 'monthly'; + } & { + [key: string]: unknown; + }; + /** + * @description Resource type. + * @enum {string} + */ + type: 'project_creation_rate'; + }; + }; + V2TransferProjectBody: { + data: { + attributes: { + target_organization_slug: string; + }; + /** + * @description Resource type. + * @enum {string} + */ + type: 'project_transfer_input'; + }; + }; + V2WorkerResponse: { + data: { + attributes: { + /** @enum {string} */ + build_state: 'active' | 'building' | 'failed'; + deleting?: boolean; + image_version?: string; + instances?: { + declared: number; + live: number; + ready: number; + stale: number; + }; + instances_error?: string; + secret_generation: string; + spec: { + /** @example public */ + exposure: string; + /** @example 1 */ + instances: number; + /** @example node */ + runtime?: string; + /** @example 2gb-1vcpu */ + size: string; + }; + state_reason?: string; + }; + /** + * @description Worker name. + * @example hello-world + */ + id: string; + /** + * @description Resource type. + * @enum {string} + */ + type: 'project_worker'; + }; + }; + V2WorkerUploadResponse: { + data: { + attributes: { + /** @description When the slot stops accepting the upload. */ + expires_at: string; + /** @example PUT */ + method: string; + /** @description Presigned destination for the `.tar.gz` build context. */ + url: string; + }; + /** + * @description Upload id to pass to the deploy endpoint as `context_upload_id`. + * @example cafe0000000000000000000000000000 + */ + id: string; + /** + * @description Resource type. + * @enum {string} + */ + type: 'project_worker_upload'; + }; + }; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + 'v2-list-organization-github-connections': { + parameters: { + query?: { + filter?: { + /** + * @description Project ref + * @example abcdefghijklmnopqrst + */ + project_ref?: string; + }; + page?: { + /** + * @description Project ref + * @example abcdefghijklmnopqrst + */ + after?: string; + /** + * @description Project ref + * @example abcdefghijklmnopqrst + */ + before?: string; + size?: number; + }; + }; + header?: never; + path: { + /** @description Organization slug */ + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ListGitHubConnectionsResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-list-organization-members': { + parameters: { + query?: { + filter?: { + /** Format: email */ + primary_email?: string; + username?: string; + }; + page?: { + /** Format: uuid */ + after?: string; + /** Format: uuid */ + before?: string; + size?: number; + }; + }; + header?: never; + path: { + /** @description Organization slug */ + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ListMembersResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-create-organization-invitations': { + parameters: { + query?: never; + header?: never; + path: { + /** @description Organization slug */ + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['V2CreateInvitationsRequest']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2CreateInvitationsResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description This feature requires the Enterprise organization plan. */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-delete-organization-invitations': { + parameters: { + query?: never; + header?: never; + path: { + /** @description Organization slug */ + slug: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['V2DeleteInvitationsRequest']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2DeleteInvitationsResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description This feature requires the Enterprise organization plan. */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-assign-organization-member-role': { + parameters: { + query?: never; + header?: never; + path: { + /** @description Organization slug */ + slug: string; + user_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['V2AssignOrganizationMemberRoleRequest']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['OrganizationMemberRoleResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description This feature requires the Enterprise organization plan. */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Failed to assign organization member role */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-get-project-creation-rate': { + parameters: { + query?: never; + header?: never; + path: { + /** @description Organization slug */ + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ProjectCreationRateResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Organization not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-list-organization-projects': { + parameters: { + query?: { + page?: { + after?: string; + before?: string; + size?: number; + }; + /** @description Case-insensitive substring match on the project name. */ + search?: string; + /** @description Sort order by creation time: `inserted_at` (oldest first) or `-inserted_at` (newest first). Defaults to `inserted_at`. */ + sort?: '-inserted_at' | 'inserted_at'; + }; + header?: never; + path: { + /** @description Organization slug */ + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ListProjectsResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-list-organization-roles': { + parameters: { + query?: never; + header?: never; + path: { + /** @description Organization slug */ + slug: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ListRolesResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-list-log-drains': { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project ref */ + ref: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ListLogDrainsResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Failed to fetch log drains */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-create-log-drain': { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project ref */ + ref: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['CreateLogDrainRequestOpenApi']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['LogDrainResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description This feature requires the Pro, Team, or Enterprise organization plan. */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Failed to create a log drain */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-update-log-drain': { + parameters: { + query?: never; + header?: never; + path: { + /** @description Log drains identifier */ + id: string; + /** @description Project ref */ + ref: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['UpdateLogDrainRequestOpenApi']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['LogDrainResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Failed to update log drain */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-delete-log-drain': { + parameters: { + query?: never; + header?: never; + path: { + /** @description Log drains identifier */ + id: string; + /** @description Project ref */ + ref: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Failed to delete a log drain */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-get-branch-creation-rate': { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project ref */ + ref: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2BranchCreationRateResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Project not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-get-project-config': { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project ref */ + ref: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ProjectConfigResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-list-private-link-associations': { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project ref */ + ref: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ListPrivateLinkAssociationsResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Failed to retrieve AWS accounts for project */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-create-private-link-association': { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project ref */ + ref: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['V2CreatePrivateLinkAssociationRequest']; + }; + }; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2PrivateLinkAssociationResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description This feature requires the Team, or Enterprise organization plan. */ + 402: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Failed to add AWS account to PrivateLink share */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-delete-private-link-association': { + parameters: { + query?: never; + header?: never; + path: { + /** @description AWS account ID used in PrivateLink association */ + aws_account_id: string; + /** @description Project ref */ + ref: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Failed to remove AWS account from PrivateLink share */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-delete-private-link-association-for-database': { + parameters: { + query?: never; + header?: never; + path: { + /** @description AWS account ID used in PrivateLink association */ + aws_account_id: string; + /** @description Identifier of the read replica this PrivateLink association targets */ + database_identifier: string; + /** @description Project ref */ + ref: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Failed to remove AWS account from PrivateLink share */ + 500: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-transfer-a-project': { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project ref */ + ref: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['V2TransferProjectBody']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-preview-a-project-transfer': { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project ref */ + ref: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['V2TransferProjectBody']; + }; + }; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2PreviewProjectTransferResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-list-all-workers': { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project ref */ + ref: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2ListWorkersResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-get-a-worker': { + parameters: { + query?: never; + header?: never; + path: { + name: string; + /** @description Project ref */ + ref: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2WorkerResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-delete-a-worker': { + parameters: { + query?: never; + header?: never; + path: { + name: string; + /** @description Project ref */ + ref: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-deploy-a-worker': { + parameters: { + query?: never; + header?: never; + path: { + name: string; + /** @description Project ref */ + ref: string; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['V2DeployWorkerRequest']; + }; + }; + responses: { + 202: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2WorkerResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; + 'v2-create-worker-upload': { + parameters: { + query?: never; + header?: never; + path: { + name: string; + /** @description Project ref */ + ref: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 201: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['V2WorkerUploadResponse']; + }; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Forbidden action */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + /** @description Rate limit exceeded */ + 429: { + headers: { + [name: string]: unknown; + }; + content: { + 'application/json': components['schemas']['ErrorResponseBody']; + }; + }; + }; + }; +} diff --git a/packages/mcp-server-supabase/src/platform/api-platform.test.ts b/packages/mcp-server-supabase/src/platform/api-platform.test.ts new file mode 100644 index 00000000..cad2048c --- /dev/null +++ b/packages/mcp-server-supabase/src/platform/api-platform.test.ts @@ -0,0 +1,156 @@ +import type { SetupServer } from 'msw/node'; +import { afterEach, beforeEach, describe, expect, test } from 'vitest'; + +import { + ACCESS_TOKEN, + API_URL, + createOrganization, + createProject, + MCP_CLIENT_NAME, + MCP_CLIENT_VERSION, + MOCK_BRANCH_CREATION_RATE, + MOCK_PROJECT_CREATION_RATE, + queuedBranchCreationRates, + queuedProjectCreationRates, + setupMockApis, +} from '../../test/mocks.js'; +import { createSupabaseApiPlatform } from './api-platform.js'; + +/** + * The creation-rate reads go through the real adapter and the real v2 paths. + * Everywhere else these rates are stubbed at the platform seam, so this is the + * only place the request paths and the `data.attributes` envelope are + * exercised. + */ + +let mockServer: SetupServer | undefined; + +beforeEach(() => { + mockServer = setupMockApis(); +}); + +afterEach(() => { + mockServer?.close(); +}); + +async function apiPlatform() { + const platform = createSupabaseApiPlatform({ + accessToken: ACCESS_TOKEN, + apiUrl: API_URL, + }); + + // The mock API asserts the user agent on every request, which the platform + // sets when the server initializes. + await platform.init?.({ + clientInfo: { name: MCP_CLIENT_NAME, version: MCP_CLIENT_VERSION }, + clientCapabilities: {}, + }); + + return platform; +} + +async function billableOrganization() { + const org = await createOrganization({ + name: 'Paid Org', + plan: 'pro', + allowed_release_channels: ['ga'], + }); + // A paid plan absorbs its first active project, so the next one is billable. + await createProject({ + name: 'Existing', + region: 'us-east-1', + organization_id: org.id, + }); + return org; +} + +describe('project creation rate', () => { + test('unwraps the response envelope down to the rate itself', async () => { + const org = await billableOrganization(); + const platform = await apiPlatform(); + + const rate = await platform.account?.getProjectCreationRate(org.id); + + // Exactly the rate: the applicability context the endpoint also returns + // (organization slug, plan, active project count) stays out of the + // package, because nothing here decides pricing. + expect(rate).toStrictEqual(MOCK_PROJECT_CREATION_RATE); + }); + + test('returns a zero amount as the authoritative rate it is', async () => { + const org = await createOrganization({ + name: 'Free Org', + plan: 'free', + allowed_release_channels: ['ga'], + }); + const platform = await apiPlatform(); + + expect( + await platform.account?.getProjectCreationRate(org.id) + ).toStrictEqual({ ...MOCK_PROJECT_CREATION_RATE, amount: 0 }); + }); + + test('carries the currency and recurrence the endpoint reports', async () => { + const org = await billableOrganization(); + const platform = await apiPlatform(); + // Nothing about the mapping is hard-coded, so a rate in another currency + // at another interval arrives unchanged. + queuedProjectCreationRates.push({ + amount: 0.5, + currency: 'EUR', + recurrence: 'hourly', + }); + + expect( + await platform.account?.getProjectCreationRate(org.id) + ).toStrictEqual({ amount: 0.5, currency: 'EUR', recurrence: 'hourly' }); + }); + + test('a UUID-shaped identifier is not an organization slug', async () => { + const platform = await apiPlatform(); + + // The v2 route resolves a slug and nothing else. A client's + // `organization_id` already holds a slug, because v1 keeps `id` as a + // deprecated alias of it, so a UUID reaching here is a caller bug and + // surfaces as the platform's own not-found error rather than a rate. + await expect( + platform.account?.getProjectCreationRate( + '3f1c9a44-6f2e-4d3b-9c8a-1e5b7d0f2a61' + ) + ).rejects.toThrowError('Organization not found'); + }); +}); + +describe('branch creation rate', () => { + test('reads the rate for the parent project, hourly', async () => { + const org = await billableOrganization(); + const project = await createProject({ + name: 'Parent', + region: 'us-east-1', + organization_id: org.id, + }); + const platform = await apiPlatform(); + + expect( + await platform.branching?.getBranchCreationRate(project.id) + ).toStrictEqual(MOCK_BRANCH_CREATION_RATE); + }); + + test('returns a zero amount as the authoritative rate it is', async () => { + const org = await billableOrganization(); + const project = await createProject({ + name: 'Parent', + region: 'us-east-1', + organization_id: org.id, + }); + const platform = await apiPlatform(); + queuedBranchCreationRates.push({ + ...MOCK_BRANCH_CREATION_RATE, + amount: 0, + }); + + expect( + await platform.branching?.getBranchCreationRate(project.id) + ).toStrictEqual({ ...MOCK_BRANCH_CREATION_RATE, amount: 0 }); + }); +}); diff --git a/packages/mcp-server-supabase/src/platform/api-platform.ts b/packages/mcp-server-supabase/src/platform/api-platform.ts index de732c3a..761ea76c 100644 --- a/packages/mcp-server-supabase/src/platform/api-platform.ts +++ b/packages/mcp-server-supabase/src/platform/api-platform.ts @@ -171,6 +171,33 @@ export function createSupabaseApiPlatform( assertSuccess(response, 'Failed to restore project'); }, + /** + * @param organizationId The organization's slug. The Management API + * reports a slug under both names: v1 marks `OrganizationResponseV1.id` + * and the create-project body's `organization_id` deprecated aliases of + * `slug`, kept for backwards compatibility, and the v1 controller returns + * `id: organization.slug`. A client's `organization_id` therefore already + * holds a slug, which is why it interpolates straight into the v2 path. + * Do not "fix" this into a UUID lookup. + */ + async getProjectCreationRate(organizationId: string) { + const response = await managementApiClient.GET( + '/v2/organizations/{slug}/project-creation-rate', + { + params: { + path: { + slug: organizationId, + }, + }, + } + ); + + assertSuccess(response, 'Failed to fetch the project creation rate'); + + const { amount, currency, recurrence } = response.data.data.attributes; + + return { amount, currency, recurrence }; + }, }; const database: DatabaseOperations = { @@ -750,6 +777,28 @@ export function createSupabaseApiPlatform( assertSuccess(response, 'Failed to rebase branch'); }, + async getBranchCreationRate(projectId: string) { + const response = await managementApiClient.GET( + '/v2/projects/{ref}/branch-creation-rate', + { + params: { + path: { + ref: projectId, + }, + }, + } + ); + + assertProjectScopedSuccess( + response, + 'Failed to fetch the branch creation rate', + projectId + ); + + const { amount, currency, recurrence } = response.data.data.attributes; + + return { amount, currency, recurrence }; + }, }; const storage: StorageOperations = { diff --git a/packages/mcp-server-supabase/src/platform/types.ts b/packages/mcp-server-supabase/src/platform/types.ts index b8c3aa2b..80fe36f3 100644 --- a/packages/mcp-server-supabase/src/platform/types.ts +++ b/packages/mcp-server-supabase/src/platform/types.ts @@ -158,6 +158,22 @@ export const generateTypescriptTypesResultSchema = z.object({ types: z.string(), }); +/** + * The authoritative rate creating one resource adds to an account, as the + * Management API reports it. + * + * Zero is authoritative: it means the resource can be created without an + * additional charge, not that the rate is unknown. This package holds no + * fallback price of its own, so a platform that cannot report a rate blocks + * creation instead of guessing one. + */ +export const creationRateSchema = z.object({ + amount: z.number(), + /** ISO 4217 currency of the amount. */ + currency: z.string(), + recurrence: z.enum(['hourly', 'monthly']), +}); + export type Organization = z.infer; export type Project = z.infer; export type Branch = z.infer; @@ -182,6 +198,7 @@ export type QueryLogsOptions = z.infer; export type GenerateTypescriptTypesResult = z.infer< typeof generateTypescriptTypesResultSchema >; +export type CreationRate = z.infer; export type StorageConfig = z.infer; export type StorageBucket = z.infer; @@ -203,6 +220,11 @@ export type AccountOperations = { createProject(options: CreateProjectOptions): Promise; pauseProject(projectId: string): Promise; restoreProject(projectId: string): Promise; + /** + * Authoritative rate the next project adds to this organization, read both + * for the initial proposal and immediately before creation. + */ + getProjectCreationRate(organizationId: string): Promise; }; export type EdgeFunctionsOperations = { @@ -273,6 +295,11 @@ export type BranchingOperations = { mergeBranch(branchId: string): Promise; resetBranch(branchId: string, options: ResetBranchOptions): Promise; rebaseBranch(branchId: string): Promise; + /** + * Authoritative rate one new branch adds to this project's organization, + * read both for the initial proposal and immediately before creation. + */ + getBranchCreationRate(projectId: string): Promise; }; export type SupabasePlatform = { diff --git a/packages/mcp-server-supabase/src/policies/cost-confirmation.ts b/packages/mcp-server-supabase/src/policies/cost-confirmation.ts new file mode 100644 index 00000000..3318b01b --- /dev/null +++ b/packages/mcp-server-supabase/src/policies/cost-confirmation.ts @@ -0,0 +1,416 @@ +import { inputRequired } from '@modelcontextprotocol/server'; +import type { + ToolPolicy, + ToolPolicyDecision, + ToolRequestContext, +} from '@supabase/mcp-utils'; + +import type { + ElicitationPolicy, + ElicitationPreparation, +} from '../elicitations/policy.js'; +import { recoveryResult } from '../elicitations/terminal.js'; +import type { CreationRate } from '../platform/types.js'; +import { isRateWithinApproved } from '../pricing.js'; + +/** + * Stable identifier for a creation refused because the authoritative rate + * moved past what was approved. It travels inside the caller-facing text so + * one string serves both a reader and a log search. + */ +export const APPROVED_RATE_STALE = 'approved_rate_stale'; + +/** + * Refuses a creation the approved ceiling no longer covers. + * + * Both paid tools call this immediately before their creation call, with a + * rate read at that moment. Nothing runs between the read and the side effect, + * so an approval cannot be spent at a price the caller never saw. + */ +export function assertRateStillApproved( + rate: CreationRate, + approved: ApprovedCreationRate +): void { + if (isRateWithinApproved(rate, approved)) { + return; + } + + throw new Error( + `The authoritative cost changed after it was approved (${APPROVED_RATE_STALE}), so nothing was created. Run the tool again to review the current cost.` + ); +} + +/** + * Version of the cost confirmation contract. + * + * Version 1 read consent from a Boolean field in the response body. Version 2 + * reads it from the wire action alone, so the two cannot interpret each + * other's state: the runtime rejects a version it does not own before it looks + * at any response, which is what makes a rolling deployment safe in both + * directions. + */ +export const POLICY_VERSION = 2; + +/** Stable policy identity bound into continuation state. */ +export const COST_CONFIRMATION_POLICY_ID = 'supabase.cost_confirmation'; + +/** + * The single embedded request key. One request means one answer, so there is + * never a question about which response carried the consent. + */ +const CONSENT_REQUEST = 'cost_confirmation'; + +/** The rate ceiling a caller approved, and the currency and interval it holds for. */ +export type ApprovedCreationRate = CreationRate; + +/** + * All a guarded tool learns from the policy: the maximum rate it may create at. + * + * No protocol fact, client label, or response content reaches business + * execution through this type. + */ +export type CostConfirmationResolution = { + maximumCreationRate: ApprovedCreationRate; +}; + +/** What is being created, in the caller's own terms. */ +export type CostConfirmationSubject = { + action: 'create_project' | 'create_branch'; + /** Name the resource will be created under. */ + resourceName: string; + /** Where the charge lands. */ + account: + | { type: 'organization'; id: string } + | { type: 'parent_project'; id: string }; +}; + +/** + * The facts a caller consents to, signed into continuation state so the answer + * resolves against the proposal that was shown rather than a fresh one. + */ +export type CostConfirmationProposal = CostConfirmationSubject & { + rate: CreationRate; +}; + +/** + * States the authoritative rate exactly as the Management API reports it: + * amount, currency, and the interval it recurs at. + * + * This is a rate, not a projection over time. Extrapolating it into a total + * needs an hours-per-month convention that Billing has not approved, and this + * package must not invent one. + */ +export function rateStatement(rate: CreationRate): string { + const interval = rate.recurrence === 'hourly' ? 'per hour' : 'per month'; + return `${rate.amount} ${rate.currency} ${interval}`; +} + +/** + * The Billing-approved projection, which does not exist yet (root gate M2). + * + * The slot is deliberately empty rather than filled with something plausible: + * a projected total Billing has not approved would be this package asserting a + * price of its own. When the convention is approved, this function returns the + * approved sentence, the message picks it up with no other change, and the one + * owning copy test swaps with it. + */ +function projectionStatement(_rate: CreationRate): string | undefined { + return undefined; +} + +/** + * Draft confirmation copy, pending Design and PM approval. + * + * The facts are load bearing and are pinned by one test: the action, the + * resource, where the charge lands, the Management API rate with its + * recurrence, that the charge recurs until the resource is deleted, and what + * accepting and declining do. The wording around them is a placeholder. It + * never depends on how a client labels its buttons, because a caller reading + * only this message still knows what accepting means. + */ +export function costConfirmationMessage( + proposal: CostConfirmationProposal +): string { + const resource = proposal.action === 'create_project' ? 'project' : 'branch'; + const where = + proposal.account.type === 'organization' + ? `in organization ${proposal.account.id}` + : `on project ${proposal.account.id}`; + const projection = projectionStatement(proposal.rate); + + return [ + `Creating the ${resource} "${proposal.resourceName}" ${where}`, + `adds ${rateStatement(proposal.rate)}, and that charge recurs until the ${resource} is deleted.`, + ...(projection === undefined ? [] : [projection]), + 'Accept to create it now, or decline to leave it uncreated.', + ].join(' '); +} + +/** + * Draft terminal copy, pending the same approval. + * + * It reports what the client said and what the server did, and claims nothing + * about a person: a client can answer without ever showing a human the prompt. + */ +function terminalMessage( + proposal: CostConfirmationProposal, + outcome: 'declined' | 'cancelled' +): string { + const resource = proposal.action === 'create_project' ? 'project' : 'branch'; + const reported = + outcome === 'declined' + ? 'The client reported that the cost was declined' + : 'The client dismissed the request without answering it'; + + return `${reported}, so no ${resource} was created.`; +} + +/** + * Explicit text for a creation that went ahead, stating what the client + * reported and what the server did. + * + * The two lanes get different sentences because only one of them asked + * anything: a zero authoritative rate is created without a prompt, so claiming + * an acceptance there would be a claim about an exchange that never happened. + * Neither sentence claims a person saw the prompt, because a client can answer + * on its own. + * + * Draft copy, pending the same Design and PM approval as the rest. + */ +export function creationOutcomeMessage( + action: CostConfirmationSubject['action'], + resourceName: string, + approved: ApprovedCreationRate +): string { + const resource = action === 'create_project' ? 'project' : 'branch'; + + if (approved.amount === 0) { + return `The authoritative rate for this ${resource} is ${rateStatement(approved)}, so no confirmation was requested. The ${resource} "${resourceName}" was created.`; + } + + return `The client reported that ${rateStatement(approved)} was accepted. The ${resource} "${resourceName}" was created.`; +} + +export type CostConfirmationPolicyOptions = { + action: CostConfirmationSubject['action']; + /** Whether this request can carry the confirmation at all. */ + available(ctx: ToolRequestContext): boolean; + /** Business arguments the approval binds to, without the legacy token. */ + canonicalArguments(args: Args): unknown; + /** What the caller is being asked about. */ + subject(args: Args): Omit; + /** The authoritative rate for this creation, read from the Management API. */ + readRate(args: Args): Promise; +}; + +/** + * The Supabase cost policy: one authoritative rate, one action-only question, + * one approved ceiling handed to the tool. + * + * State integrity, lifetime, correlation and terminal composition belong to + * the runtime this policy is handed to. What lives here is the product half: + * which rate applies, whether anything needs asking, and what an answer means. + */ +export function createCostConfirmationPolicy( + options: CostConfirmationPolicyOptions +): ElicitationPolicy< + Args, + CostConfirmationProposal, + CostConfirmationResolution +> { + return { + id: COST_CONFIRMATION_POLICY_ID, + version: POLICY_VERSION, + available: options.available, + canonicalArguments: options.canonicalArguments, + + async prepare( + args + ): Promise< + ElicitationPreparation< + CostConfirmationProposal, + CostConfirmationResolution + > + > { + const rate = await options.readRate(args); + + // Zero is an authoritative answer, not a missing one: there is nothing + // to consent to. The rate still travels into execution as the approved + // ceiling, so the check immediately before creation still catches a rate + // that moved. + if (rate.amount === 0) { + return { type: 'execute', resolution: { maximumCreationRate: rate } }; + } + + return { + type: 'elicit', + proposal: { action: options.action, ...options.subject(args), rate }, + }; + }, + + inputRequests(proposal) { + return { + [CONSENT_REQUEST]: inputRequired.elicit({ + message: costConfirmationMessage(proposal), + // Property-less by contract: with no properties there is no field a + // client could fill in, so consent can only come from the action. + requestedSchema: { type: 'object', properties: {} }, + }), + }; + }, + + async resolve(proposal, inputResponses) { + const answer = inputResponses[CONSENT_REQUEST]; + + // No answer to the question that was asked. Asking again is the only + // safe reading: a missing response is not consent, and it is not a + // refusal either. + if (answer === undefined || answer.kind !== 'elicit') { + return { type: 'reissue' }; + } + + switch (answer.action) { + case 'accept': + // The wire action is the whole answer. Response content is never + // read, so no Boolean, string, or absent field can grant, weaken, or + // withdraw this consent. + return { + type: 'execute', + resolution: { maximumCreationRate: proposal.rate }, + }; + case 'decline': + return { + type: 'declined', + message: terminalMessage(proposal, 'declined'), + }; + case 'cancel': + return { + type: 'cancelled', + message: terminalMessage(proposal, 'cancelled'), + }; + default: + return { type: 'reissue' }; + } + }, + }; +} + +/** + * Guidance for a capable client that called the retired confirmation tool by + * name. Draft copy, pending the same approval as the rest. + */ +const MIGRATION_GUIDANCE = + 'This client confirms costs inside the tool that creates the resource. Call create_project or create_branch directly and answer the confirmation it requests; a separate confirmation ID is not needed.'; + +/** The legacy confirmation token, split out of the business arguments. */ +export const LEGACY_CONFIRMATION_FIELD = 'confirm_cost_id'; + +const LEGACY_TELEMETRY = { + policyId: COST_CONFIRMATION_POLICY_ID, + policyVersion: POLICY_VERSION, + authorityPath: 'legacy_confirmation', +} as const; + +/** + * Chooses the authority path for one paid creation call, before the + * elicitation runtime is consulted. + * + * A request that cannot carry a form takes the legacy lane it has always + * taken: the same required token, the same schema, the same check inside the + * tool. Only a request that can carry a form reaches the runtime. The + * runtime's own refusal for an incapable client stays as a backstop for a + * consumer that attaches this policy where forms cannot be delivered; it is + * never how a legacy caller is routed. + */ +export function routeCostConfirmation(options: { + capable(ctx: ToolRequestContext): boolean; + confirmed: ToolPolicy; +}): ToolPolicy { + const { capable, confirmed } = options; + + return { + inputSchema(schema, ctx) { + const contextual = confirmed.inputSchema?.(schema, ctx) ?? schema; + + // The legacy token is not part of the modern contract, so a capable + // client is never shown a field it must not use. + return capable(ctx) + ? contextual.omit({ [LEGACY_CONFIRMATION_FIELD]: true }) + : contextual; + }, + + outputSchema(schema, ctx) { + return confirmed.outputSchema?.(schema, ctx) ?? schema; + }, + + normalizeArguments(raw, ctx) { + const normalized = confirmed.normalizeArguments?.(raw, ctx) ?? raw; + + if ( + !capable(ctx) || + normalized === null || + typeof normalized !== 'object' + ) { + return normalized; + } + + // Dropped before canonicalization, so a token supplied anyway is not + // part of what an approval binds to and cannot stand in for one. + const { [LEGACY_CONFIRMATION_FIELD]: _ignored, ...business } = + normalized as Record; + + return business; + }, + + async resolve( + args, + ctx + ): Promise> { + if (capable(ctx)) { + return confirmed.resolve(args, ctx); + } + + // No resolution: the tool falls back to the legacy token check it has + // always run, and nothing about this request reaches the runtime. + return { + type: 'execute', + resolution: undefined, + telemetry: { ...LEGACY_TELEMETRY, outcome: 'executed' }, + }; + }, + }; +} + +/** + * Answers a direct call to the retired confirmation tool. + * + * The handler stays registered for every caller that still needs it. A capable + * client that calls it by name gets guidance instead of a confirmation ID, + * because a token it obtained here would be ignored by the creation call + * anyway. + */ +export function routeLegacyConfirmation(options: { + capable(ctx: ToolRequestContext): boolean; +}): ToolPolicy { + return { + async resolve(_args, ctx): Promise> { + if (options.capable(ctx)) { + return { + type: 'result', + result: recoveryResult(MIGRATION_GUIDANCE), + telemetry: { + ...LEGACY_TELEMETRY, + outcome: 'rejected', + reason: 'legacy_confirmation_retired', + }, + }; + } + + return { + type: 'execute', + resolution: undefined, + telemetry: { ...LEGACY_TELEMETRY, outcome: 'executed' }, + }; + }, + }; +} diff --git a/packages/mcp-server-supabase/src/pricing.ts b/packages/mcp-server-supabase/src/pricing.ts index 960bbae3..5c44c224 100644 --- a/packages/mcp-server-supabase/src/pricing.ts +++ b/packages/mcp-server-supabase/src/pricing.ts @@ -1,53 +1,60 @@ -import type { AccountOperations } from './platform/types.js'; +import type { CreationRate } from './platform/types.js'; -export const PROJECT_COST_MONTHLY = 10; -export const BRANCH_COST_HOURLY = 0.01344; - -export type ProjectCost = { - type: 'project'; - recurrence: 'monthly'; - amount: number; -}; - -export type BranchCost = { - type: 'branch'; - recurrence: 'hourly'; +/** + * The cost shape the legacy `get_cost` and `confirm_cost` pair speaks. + * + * It carries no currency, because the hash a legacy confirmation is identified + * by is computed over exactly these fields. Adding one would invalidate every + * confirmation a legacy client is holding. + */ +export type Cost = { + type: 'project' | 'branch'; + recurrence: 'hourly' | 'monthly'; amount: number; }; -export type Cost = ProjectCost | BranchCost; - /** - * Gets the cost of the next project in an organization. + * Presents an authoritative rate as the legacy cost shape. + * + * This is the whole adapter: the rate itself comes from the Management API, so + * this package holds no price of its own to fall back to. */ -export async function getNextProjectCost( - account: AccountOperations, - orgId: string -): Promise { - const org = await account.getOrganization(orgId); - const projects = await account.listProjects(); - - const activeProjects = projects.filter( - (project) => - project.organization_id === orgId && - !['INACTIVE', 'GOING_DOWN', 'REMOVED'].includes(project.status) - ); - - let amount = 0; - - if (org.plan !== 'free') { - // If the organization is on a paid plan, the first project is included - if (activeProjects.length > 0) { - amount = PROJECT_COST_MONTHLY; - } - } +export function toCost(type: Cost['type'], rate: CreationRate): Cost { + return { type, recurrence: rate.recurrence, amount: rate.amount }; +} - return { type: 'project', recurrence: 'monthly', amount }; +/** + * The hourly branch rate the legacy confirmation pair agrees on. + * + * The authoritative branch rate is scoped to the parent project, and legacy + * `get_cost` has no project reference to read it with: its only argument is an + * organization. Both halves of the legacy pair therefore keep quoting this + * value, which is the same rate Billing's catalog reports today, so a legacy + * confirmation still matches the cost the legacy creation path recomputes. + * + * It is not a fallback: no authoritative read ever resolves to it. The v2 + * confirmation path reads the Management API for the parent project and never + * calls this. + */ +export function legacyBranchCost(): Cost { + return { type: 'branch', recurrence: 'hourly', amount: 0.01344 }; } /** - * Gets the cost for a database branch. + * Whether a freshly read rate is still covered by the maximum a caller + * approved. + * + * An equal or lower amount proceeds; a higher amount does not. Recurrence and + * currency must be unchanged, because a lower number under a different + * interval or currency is not a lower price. */ -export function getBranchCost(): Cost { - return { type: 'branch', recurrence: 'hourly', amount: BRANCH_COST_HOURLY }; +export function isRateWithinApproved( + rate: CreationRate, + approved: CreationRate +): boolean { + return ( + rate.currency === approved.currency && + rate.recurrence === approved.recurrence && + rate.amount <= approved.amount + ); } diff --git a/packages/mcp-server-supabase/src/server.test.ts b/packages/mcp-server-supabase/src/server.test.ts index b411e3f0..908e7494 100644 --- a/packages/mcp-server-supabase/src/server.test.ts +++ b/packages/mcp-server-supabase/src/server.test.ts @@ -17,12 +17,13 @@ import { createProject, MCP_CLIENT_NAME, MCP_CLIENT_VERSION, + MOCK_BRANCH_CREATION_RATE, + MOCK_PROJECT_CREATION_RATE, mockContentApiSchemaLoadCount, setupMockApis, } from '../test/mocks.js'; import { createSupabaseApiPlatform } from './platform/api-platform.js'; import type { SupabasePlatform } from './platform/types.js'; -import { BRANCH_COST_HOURLY, PROJECT_COST_MONTHLY } from './pricing.js'; import { createSupabaseMcpServer, instructions } from './server.js'; import { createToolSchemas, @@ -247,7 +248,7 @@ describe('tools', () => { expect(result).toEqual({ type: 'project', - amount: PROJECT_COST_MONTHLY, + amount: MOCK_PROJECT_CREATION_RATE.amount, recurrence: 'monthly', }); }); @@ -302,7 +303,7 @@ describe('tools', () => { expect(result).toEqual({ type: 'branch', - amount: BRANCH_COST_HOURLY, + amount: MOCK_BRANCH_CREATION_RATE.amount, recurrence: 'hourly', }); }); @@ -3078,7 +3079,7 @@ describe('tools', () => { arguments: { type: 'branch', recurrence: 'hourly', - amount: BRANCH_COST_HOURLY, + amount: MOCK_BRANCH_CREATION_RATE.amount, }, }); @@ -3134,7 +3135,7 @@ describe('tools', () => { arguments: { type: 'branch', recurrence: 'hourly', - amount: BRANCH_COST_HOURLY, + amount: MOCK_BRANCH_CREATION_RATE.amount, }, }); @@ -3206,7 +3207,7 @@ describe('tools', () => { arguments: { type: 'branch', recurrence: 'hourly', - amount: BRANCH_COST_HOURLY, + amount: MOCK_BRANCH_CREATION_RATE.amount, }, }); @@ -3361,7 +3362,7 @@ describe('tools', () => { arguments: { type: 'branch', recurrence: 'hourly', - amount: BRANCH_COST_HOURLY, + amount: MOCK_BRANCH_CREATION_RATE.amount, }, }); @@ -3466,7 +3467,7 @@ describe('tools', () => { arguments: { type: 'branch', recurrence: 'hourly', - amount: BRANCH_COST_HOURLY, + amount: MOCK_BRANCH_CREATION_RATE.amount, }, }); @@ -3580,7 +3581,7 @@ describe('tools', () => { arguments: { type: 'branch', recurrence: 'hourly', - amount: BRANCH_COST_HOURLY, + amount: MOCK_BRANCH_CREATION_RATE.amount, }, }); @@ -3682,7 +3683,7 @@ describe('tools', () => { arguments: { type: 'branch', recurrence: 'hourly', - amount: BRANCH_COST_HOURLY, + amount: MOCK_BRANCH_CREATION_RATE.amount, }, }); diff --git a/packages/mcp-server-supabase/src/server.ts b/packages/mcp-server-supabase/src/server.ts index 4a891659..cbe7859e 100644 --- a/packages/mcp-server-supabase/src/server.ts +++ b/packages/mcp-server-supabase/src/server.ts @@ -15,7 +15,7 @@ import { getDocsTools } from './tools/docs-tools.js'; import { getEdgeFunctionTools } from './tools/edge-function-tools.js'; import { getStorageTools } from './tools/storage-tools.js'; import { writeToolSet } from './tools/tool-schemas.js'; -import type { FeatureGroup } from './types.js'; +import { PLATFORM_INDEPENDENT_FEATURES, type FeatureGroup } from './types.js'; import { parseFeatureGroups } from './util.js'; const { version } = packageJson; @@ -66,8 +66,6 @@ const DEFAULT_FEATURES: FeatureGroup[] = [ 'branching', ]; -export const PLATFORM_INDEPENDENT_FEATURES: FeatureGroup[] = ['docs']; - export const instructions = ` Here are guidelines for using Supabase tools effectively: diff --git a/packages/mcp-server-supabase/src/tools/account-tools.ts b/packages/mcp-server-supabase/src/tools/account-tools.ts index e35b8ceb..dc22843c 100644 --- a/packages/mcp-server-supabase/src/tools/account-tools.ts +++ b/packages/mcp-server-supabase/src/tools/account-tools.ts @@ -3,7 +3,11 @@ import { z } from 'zod/v4'; import type { ToolDefs } from './util.js'; import type { AccountOperations } from '../platform/types.js'; import { organizationSchema, projectSchema } from '../platform/types.js'; -import { getBranchCost, getNextProjectCost } from '../pricing.js'; +import { + assertRateStillApproved, + type CostConfirmationResolution, +} from '../policies/cost-confirmation.js'; +import { legacyBranchCost, toCost } from '../pricing.js'; import { AWS_REGION_CODES } from '../regions.js'; import { hashObject } from '../util.js'; @@ -246,9 +250,12 @@ export function getAccountTools({ account, readOnly }: AccountToolsOptions) { execute: async ({ type, organization_id }) => { switch (type) { case 'project': - return await getNextProjectCost(account, organization_id); + return toCost( + 'project', + await account.getProjectCreationRate(organization_id) + ); case 'branch': - return getBranchCost(); + return legacyBranchCost(); default: throw new Error(`Unknown cost type: ${type}`); } @@ -260,18 +267,35 @@ export function getAccountTools({ account, readOnly }: AccountToolsOptions) { return { confirmation_id: await hashObject(cost) }; }, }), - create_project: tool({ + create_project: tool< + typeof createProjectInputSchema, + typeof createProjectOutputSchema, + CostConfirmationResolution | undefined + >({ ...accountToolDefs.create_project, - execute: async ({ name, region, organization_id, confirm_cost_id }) => { + execute: async ( + { name, region, organization_id, confirm_cost_id }, + resolution + ) => { if (readOnly) { throw new Error('Cannot create a project in read-only mode.'); } - const cost = await getNextProjectCost(account, organization_id); - const costHash = await hashObject(cost); - if (costHash !== confirm_cost_id) { - throw new Error( - 'Cost confirmation ID does not match the expected cost of creating a project.' + if (resolution === undefined) { + const cost = toCost( + 'project', + await account.getProjectCreationRate(organization_id) + ); + const costHash = await hashObject(cost); + if (costHash !== confirm_cost_id) { + throw new Error( + 'Cost confirmation ID does not match the expected cost of creating a project.' + ); + } + } else { + assertRateStillApproved( + await account.getProjectCreationRate(organization_id), + resolution.maximumCreationRate ); } diff --git a/packages/mcp-server-supabase/src/tools/branching-tools.ts b/packages/mcp-server-supabase/src/tools/branching-tools.ts index bbad5976..dc5aed59 100644 --- a/packages/mcp-server-supabase/src/tools/branching-tools.ts +++ b/packages/mcp-server-supabase/src/tools/branching-tools.ts @@ -2,7 +2,11 @@ import { tool } from '@supabase/mcp-utils'; import { z } from 'zod/v4'; import type { BranchingOperations } from '../platform/types.js'; import { branchSchema } from '../platform/types.js'; -import { getBranchCost } from '../pricing.js'; +import { + assertRateStillApproved, + type CostConfirmationResolution, +} from '../policies/cost-confirmation.js'; +import { legacyBranchCost } from '../pricing.js'; import { hashObject } from '../util.js'; import { injectableTool, type ToolDefs } from './util.js'; @@ -159,21 +163,36 @@ export function getBranchingTools({ const project_id = projectId; return { - create_branch: injectableTool({ + create_branch: injectableTool< + typeof createBranchInputSchema, + typeof createBranchOutputSchema, + { project_id: string | undefined }, + CostConfirmationResolution | undefined + >({ ...branchingToolDefs.create_branch, inject: { project_id }, - execute: async ({ project_id, name, confirm_cost_id }) => { + execute: async ({ project_id, name, confirm_cost_id }, resolution) => { if (readOnly) { throw new Error('Cannot create a branch in read-only mode.'); } - const cost = getBranchCost(); - const costHash = await hashObject(cost); - if (costHash !== confirm_cost_id) { - throw new Error( - 'Cost confirmation ID does not match the expected cost of creating a branch.' + if (resolution === undefined) { + const cost = legacyBranchCost(); + const costHash = await hashObject(cost); + if (costHash !== confirm_cost_id) { + throw new Error( + 'Cost confirmation ID does not match the expected cost of creating a branch.' + ); + } + } else { + // Read here and nowhere earlier: nothing runs between this rate and + // the creation call below. + assertRateStillApproved( + await branching.getBranchCreationRate(project_id), + resolution.maximumCreationRate ); } + return await branching.createBranch(project_id, { name }); }, }), diff --git a/packages/mcp-server-supabase/src/tools/util.ts b/packages/mcp-server-supabase/src/tools/util.ts index 14cd7807..267f70c8 100644 --- a/packages/mcp-server-supabase/src/tools/util.ts +++ b/packages/mcp-server-supabase/src/tools/util.ts @@ -23,7 +23,8 @@ export type InjectableTool< Params extends z.ZodObject, OutputSchema extends z.ZodObject, Injected extends Partial> = {}, -> = Tool & { + Resolution = never, +> = Tool & { /** * Optionally injects static parameter values into the tool's * execute function and removes them from the parameter schema. @@ -38,15 +39,19 @@ export function injectableTool< Params extends z.ZodObject, OutputSchema extends z.ZodObject, Injected extends Partial>, + Resolution = never, >({ description, annotations, parameters, outputSchema, hidden, + visible, + policy, inject, execute, -}: InjectableTool) { + formatResult, +}: InjectableTool) { // If all injected parameters are undefined, return the original tool if (!inject || Object.values(inject).every((value) => value === undefined)) { return tool({ @@ -55,7 +60,10 @@ export function injectableTool< parameters, outputSchema, hidden, + visible, + policy, execute, + formatResult, }); } @@ -71,9 +79,10 @@ export function injectableTool< // Wrapper that merges injected values with provided args const executeWithInjection = async ( - args: z.infer + args: z.infer, + ...resolution: [Resolution] extends [never] ? [] : [Resolution] ) => { - return execute({ ...args, ...inject } as z.infer); + return execute({ ...args, ...inject } as z.infer, ...resolution); }; return tool({ @@ -82,7 +91,18 @@ export function injectableTool< parameters: cleanParametersSchema, outputSchema, hidden, + visible, + // A policy decides on the same arguments the tool executes with, so the + // injected values are merged in before it sees them. Without this, a + // project-scoped server would ask a policy to reason about a project it + // was never told about. + policy: policy && { + ...policy, + resolve: (args, ctx) => + policy.resolve({ ...args, ...inject } as z.infer, ctx), + }, execute: executeWithInjection, + formatResult, }); } diff --git a/packages/mcp-server-supabase/src/types.ts b/packages/mcp-server-supabase/src/types.ts index 9260a72d..c89379a6 100644 --- a/packages/mcp-server-supabase/src/types.ts +++ b/packages/mcp-server-supabase/src/types.ts @@ -11,6 +11,15 @@ export const CURRENT_FEATURE_GROUPS = [ 'storage', ] as const; +/** + * Feature groups that need no platform implementation behind them. + * + * It lives beside the feature groups themselves rather than next to the server + * that consumes it, so a module reading feature facts never has to import the + * server and the cycle that used to create. + */ +export const PLATFORM_INDEPENDENT_FEATURES: FeatureGroup[] = ['docs']; + export const deprecatedFeatureGroupSchema = z.enum(['debug']); export const currentFeatureGroupSchema = z.enum(CURRENT_FEATURE_GROUPS); diff --git a/packages/mcp-server-supabase/src/util.ts b/packages/mcp-server-supabase/src/util.ts index 9e3f499d..97312426 100644 --- a/packages/mcp-server-supabase/src/util.ts +++ b/packages/mcp-server-supabase/src/util.ts @@ -1,9 +1,9 @@ import { z } from 'zod/v4'; import type { SupabasePlatform } from './platform/types.js'; -import { PLATFORM_INDEPENDENT_FEATURES } from './server.js'; import { currentFeatureGroupSchema, featureGroupSchema, + PLATFORM_INDEPENDENT_FEATURES, type FeatureGroup, } from './types.js'; diff --git a/packages/mcp-server-supabase/test/mocks.ts b/packages/mcp-server-supabase/test/mocks.ts index 79ee03a3..fdd55d21 100644 --- a/packages/mcp-server-supabase/test/mocks.ts +++ b/packages/mcp-server-supabase/test/mocks.ts @@ -15,6 +15,7 @@ import { } from '../src/content-api/graphql.js'; import { getDeploymentId, getPathPrefix } from '../src/edge-function.js'; import type { components } from '../src/management-api/types.js'; +import type { CreationRate } from '../src/platform/types.js'; const { version } = packageJson; @@ -85,6 +86,30 @@ export const mockOrgs = new Map(); export const mockProjects = new Map(); export const mockBranches = new Map(); +/** + * Rates the mock Management API reports. They are the values Billing's addon + * catalog returns today, and they live here rather than in the package because + * the package holds no price of its own. + */ +export const MOCK_PROJECT_CREATION_RATE: CreationRate = { + amount: 10, + currency: 'USD', + recurrence: 'monthly', +}; +export const MOCK_BRANCH_CREATION_RATE: CreationRate = { + amount: 0.01344, + currency: 'USD', + recurrence: 'hourly', +}; + +/** + * Authoritative creation rates the next reads return, one per read, before the + * mock falls back to the rate Billing would compute. A test queues entries to + * reproduce a rate that moves between the proposal and the creation call. + */ +export const queuedProjectCreationRates: CreationRate[] = []; +export const queuedBranchCreationRates: CreationRate[] = []; + export const mockContentApiSchemaLoadCount = { value: 0 }; export const mockContentApi = [ @@ -266,6 +291,84 @@ export const mockManagementApi = [ return HttpResponse.json(organization); }), + /** + * Authoritative rate for the next project in an organization + */ + http.get( + `${API_URL}/v2/organizations/:slug/project-creation-rate`, + ({ params }) => { + // Slug-strict, like the route: v2 paths take a slug, and nothing else + // resolves. A UUID-shaped identifier is not found here, exactly as it + // would not be found in production. + const organization = Array.from(mockOrgs.values()).find( + (org) => org.slug === params.slug + ); + + if (!organization) { + return HttpResponse.json( + { message: 'Organization not found' }, + { status: 404 } + ); + } + + const activeProjects = Array.from(mockProjects.values()).filter( + (project) => + project.organization_id === organization.id && + !['INACTIVE', 'GOING_DOWN', 'REMOVED'].includes(project.status) + ); + + // Billing absorbs the first active project of a paid plan into the + // organization's compute credits, and a free plan is never charged. + const billable = + organization.plan !== 'free' && activeProjects.length > 0; + const rate = + queuedProjectCreationRates.shift() ?? + (billable + ? MOCK_PROJECT_CREATION_RATE + : { ...MOCK_PROJECT_CREATION_RATE, amount: 0 }); + + return HttpResponse.json({ + data: { + type: 'project_creation_rate', + attributes: { + ...rate, + organization_slug: organization.slug, + plan_id: organization.plan, + active_project_count: activeProjects.length, + }, + }, + }); + } + ), + + /** + * Authoritative rate for one new branch of a project + */ + http.get(`${API_URL}/v2/projects/:ref/branch-creation-rate`, ({ params }) => { + const project = mockProjects.get(params.ref as string); + + if (!project) { + return HttpResponse.json( + { message: 'Project not found' }, + { status: 404 } + ); + } + + const rate = queuedBranchCreationRates.shift() ?? MOCK_BRANCH_CREATION_RATE; + + return HttpResponse.json({ + data: { + type: 'branch_creation_rate', + attributes: { + ...rate, + organization_slug: project.organization_slug, + plan_id: 'pro', + parent_project_ref: project.ref, + }, + }, + }); + }), + /** * Get the API keys for a project */ @@ -939,6 +1042,8 @@ export function setupMockApis(): SetupServer { mockOrgs.clear(); mockProjects.clear(); mockBranches.clear(); + queuedProjectCreationRates.length = 0; + queuedBranchCreationRates.length = 0; mockContentApiSchemaLoadCount.value = 0; const mockServer = setupServer(...mockContentApi, ...mockManagementApi); @@ -1034,7 +1139,11 @@ export class MockOrganization { constructor(options: MockOrganizationOptions) { this.id = nanoid(); - this.slug = nanoid(); + // The Management API reports an organization's slug under both names: v1 + // marks `id` a deprecated alias of `slug`, kept for backwards + // compatibility. Mirroring that here keeps a test honest about what a + // client actually holds in `organization_id`. + this.slug = this.id; this.name = options.name; this.plan = options.plan; this.allowed_release_channels = options.allowed_release_channels; From 8dfbb17dd255e1bbb45cc21d940d30e04acf1a92 Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Mon, 24 Aug 2026 22:30:50 +0200 Subject: [PATCH 2/4] feat: route hosted cost confirmation by capability Attaches the cost policy to `create_project` and `create_branch`, and routes each request to the lane it belongs in before the elicitation runtime is consulted. A request that can carry a form takes the confirmed lane: the legacy token is hidden from its schema, stripped before canonicalization so a token supplied anyway cannot bind an approval, and `confirm_cost` disappears from its tool list while staying callable with migration guidance. Every other request takes the legacy lane unchanged, and the router composes the runtime's output-schema hook rather than re-deciding availability, so a legacy request keeps its pre-normalization bytes: no `outputSchema` in discovery, no `structuredContent`, single-encoded text. A request carrying verified continuation state stays on the confirmed lane even when this leg is no longer capable. Continuation wins routing, so capability lost mid-flow is answered with the runtime's recovery text instead of a demand for a token the caller was never given. The whole surface is opt-in through one server option. A consumer that injects no elicitation options gets policy-free tools, which is how deprecated stdio, classic hosted, and a hosted connection that opted out keep the contract they have today. The two byte fixtures land with this commit because they defend exactly what it changes. Their expected values were measured against base main (302d2ad7870352444ca0d71711622ab38a66e4ff) with fixed platform objects, and they cover both discovery entries and both call results on the legacy path. --- packages/mcp-server-supabase/src/index.ts | 1 + .../src/policies/cost-confirmation.ts | 36 +- .../src/policies/legacy-cost-bytes.test.ts | 320 ++++++++++++++++++ packages/mcp-server-supabase/src/server.ts | 99 +++++- .../src/tools/account-tools.ts | 52 ++- .../src/tools/branching-tools.ts | 31 +- 6 files changed, 530 insertions(+), 9 deletions(-) create mode 100644 packages/mcp-server-supabase/src/policies/legacy-cost-bytes.test.ts diff --git a/packages/mcp-server-supabase/src/index.ts b/packages/mcp-server-supabase/src/index.ts index bc20ab46..ae568a0e 100644 --- a/packages/mcp-server-supabase/src/index.ts +++ b/packages/mcp-server-supabase/src/index.ts @@ -4,6 +4,7 @@ export type { ToolCallCallback } from '@supabase/mcp-utils'; export type { SupabasePlatform } from './platform/index.js'; export { createSupabaseMcpServer, + type SupabaseElicitationOptions, type SupabaseMcpServerOptions, } from './server.js'; export { createSupabaseMcpHandler } from './transports/http.js'; diff --git a/packages/mcp-server-supabase/src/policies/cost-confirmation.ts b/packages/mcp-server-supabase/src/policies/cost-confirmation.ts index 3318b01b..5a9c1c85 100644 --- a/packages/mcp-server-supabase/src/policies/cost-confirmation.ts +++ b/packages/mcp-server-supabase/src/policies/cost-confirmation.ts @@ -9,6 +9,7 @@ import type { ElicitationPolicy, ElicitationPreparation, } from '../elicitations/policy.js'; +import type { VerifiedContinuation } from '../elicitations/state.js'; import { recoveryResult } from '../elicitations/terminal.js'; import type { CreationRate } from '../platform/types.js'; import { isRateWithinApproved } from '../pricing.js'; @@ -328,26 +329,44 @@ export function routeCostConfirmation(options: { }): ToolPolicy { const { capable, confirmed } = options; + /** + * Whether the runtime owns this request. + * + * A request carrying verified continuation state belongs to a flow that + * already started, so it stays on the confirmed lane even if this leg is no + * longer capable. Handing it to the legacy lane instead would demand a token + * the caller was never given and answer capability loss with a hash + * mismatch, where the runtime answers it with recovery text. State that + * fails integrity, actor, or method binding never reaches here at all. + */ + const runtimeOwns = (ctx: ToolRequestContext) => + ctx.server.mcpReq.requestState() !== undefined || + capable(ctx); + return { inputSchema(schema, ctx) { const contextual = confirmed.inputSchema?.(schema, ctx) ?? schema; // The legacy token is not part of the modern contract, so a capable // client is never shown a field it must not use. - return capable(ctx) + return runtimeOwns(ctx) ? contextual.omit({ [LEGACY_CONFIRMATION_FIELD]: true }) : contextual; }, outputSchema(schema, ctx) { - return confirmed.outputSchema?.(schema, ctx) ?? schema; + // Composed, never re-decided, and never defaulted: the runtime widens + // the schema for a request that can reach a terminal outcome and + // returns undefined for one that cannot. Passing that undefined through + // is what holds a legacy request on its pre-normalization bytes. + return confirmed.outputSchema?.(schema, ctx); }, normalizeArguments(raw, ctx) { const normalized = confirmed.normalizeArguments?.(raw, ctx) ?? raw; if ( - !capable(ctx) || + !runtimeOwns(ctx) || normalized === null || typeof normalized !== 'object' ) { @@ -366,7 +385,7 @@ export function routeCostConfirmation(options: { args, ctx ): Promise> { - if (capable(ctx)) { + if (runtimeOwns(ctx)) { return confirmed.resolve(args, ctx); } @@ -393,6 +412,15 @@ export function routeLegacyConfirmation(options: { capable(ctx: ToolRequestContext): boolean; }): ToolPolicy { return { + outputSchema(schema, ctx) { + // An incapable caller keeps this tool exactly as it always was, which + // means no structured results at all. A capable caller never sees it in + // discovery, and its direct call is answered with guidance rather than + // an execution, so normalizing that request changes nothing it can + // observe. + return options.capable(ctx) ? schema : undefined; + }, + async resolve(_args, ctx): Promise> { if (options.capable(ctx)) { return { diff --git a/packages/mcp-server-supabase/src/policies/legacy-cost-bytes.test.ts b/packages/mcp-server-supabase/src/policies/legacy-cost-bytes.test.ts new file mode 100644 index 00000000..13c4b1e4 --- /dev/null +++ b/packages/mcp-server-supabase/src/policies/legacy-cost-bytes.test.ts @@ -0,0 +1,320 @@ +import { Client } from '@modelcontextprotocol/client'; +import { + createMcpServer, + StreamTransport, + type Tool, +} from '@supabase/mcp-utils'; +import { expect, test } from 'vitest'; + +import { createElicitationRuntime } from '../elicitations/runtime.js'; +import type { + AccountOperations, + BranchingOperations, + CreationRate, +} from '../platform/types.js'; +import { getAccountTools } from '../tools/account-tools.js'; +import { getBranchingTools } from '../tools/branching-tools.js'; +import { + createCostConfirmationPolicy, + routeCostConfirmation, + routeLegacyConfirmation, +} from './cost-confirmation.js'; + +/** + * A legacy caller must keep the bytes it has always received, whether or not + * this connection also serves form elicitation to modern capable clients. + * + * The expected values below were measured, not written by hand: they are the + * output of this exact fixture's platform objects against base main + * (302d2ad7870352444ca0d71711622ab38a66e4ff), whose paid tools carry no + * policy. Every field is fixed, so the strings are stable across runs. + */ +const BASE_CREATE_PROJECT_ENTRY = + '{"name":"create_project","description":"Creates a new Supabase project. Always ask the user which organization to create the project in. The project can take a few minutes to initialize - use `get_project` to check the status.","inputSchema":{"type":"object","properties":{"name":{"description":"The name of the project","type":"string"},"region":{"description":"The region to create the project in.","type":"string","enum":["us-west-1","us-east-1","us-east-2","ca-central-1","eu-west-1","eu-west-2","eu-west-3","eu-central-1","eu-central-2","eu-north-1","ap-south-1","ap-southeast-1","ap-northeast-1","ap-northeast-2","ap-southeast-2","sa-east-1"]},"organization_id":{"type":"string"},"confirm_cost_id":{"description":"The cost confirmation ID. Call `confirm_cost` first.","type":"string"}},"required":["name","region","organization_id","confirm_cost_id"],"$schema":"http://json-schema.org/draft-07/schema#","additionalProperties":false},"annotations":{"title":"Create project","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}'; + +const BASE_CREATE_BRANCH_ENTRY = + '{"name":"create_branch","description":"Creates a development branch on a Supabase project. This will apply all migrations from the main project to a fresh branch database. Note that production data will not carry over. The branch will get its own project_id via the resulting project_ref. Use this ID to execute queries and migrations on the branch.","inputSchema":{"type":"object","properties":{"project_id":{"type":"string"},"name":{"description":"Name of the branch to create","default":"develop","type":"string"},"confirm_cost_id":{"description":"The cost confirmation ID. Call `confirm_cost` first.","type":"string"}},"required":["project_id","name","confirm_cost_id"],"$schema":"http://json-schema.org/draft-07/schema#","additionalProperties":false},"annotations":{"title":"Create branch","readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false}}'; + +const BASE_CONFIRM_COST_ENTRY = + '{"name":"confirm_cost","description":"Ask the user to confirm their understanding of the cost of creating a new project or branch. Call `get_cost` first. Returns a unique ID for this confirmation which should be passed to `create_project` or `create_branch`.","inputSchema":{"type":"object","properties":{"type":{"type":"string","enum":["project","branch"]},"recurrence":{"type":"string","enum":["hourly","monthly"]},"amount":{"type":"number"}},"required":["type","recurrence","amount"],"$schema":"http://json-schema.org/draft-07/schema#","additionalProperties":false},"annotations":{"title":"Confirm cost understanding","readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false}}'; + +const BASE_CONFIRM_COST_PROJECT_RESULT = + '{"content":[{"type":"text","text":"{\\"confirmation_id\\":\\"BGoZHqqJd2JYMt+cWSDFH7qDeNkZZAwbTytJrHy7r+E=\\"}"}]}'; + +const BASE_CREATE_PROJECT_RESULT = + '{"content":[{"type":"text","text":"{\\"id\\":\\"fixed-project-ref\\",\\"ref\\":\\"fixed-project-ref\\",\\"organization_id\\":\\"fixed-org\\",\\"organization_slug\\":\\"fixed-org\\",\\"name\\":\\"Fixture Project\\",\\"status\\":\\"UNKNOWN\\",\\"created_at\\":\\"2026-01-01T00:00:00.000Z\\",\\"region\\":\\"us-east-1\\"}"}]}'; + +const BASE_CREATE_BRANCH_RESULT = + '{"content":[{"type":"text","text":"{\\"id\\":\\"fixed-branch\\",\\"name\\":\\"develop\\",\\"project_ref\\":\\"fixed-branch-ref\\",\\"parent_project_ref\\":\\"fixed-project-ref\\",\\"is_default\\":false,\\"persistent\\":false,\\"status\\":\\"CREATING_PROJECT\\",\\"created_at\\":\\"2026-01-01T00:00:00.000Z\\",\\"updated_at\\":\\"2026-01-01T00:00:00.000Z\\"}"}]}'; + +const PROJECT_RATE: CreationRate = { + amount: 10, + currency: 'USD', + recurrence: 'monthly', +}; +const BRANCH_RATE: CreationRate = { + amount: 0.01344, + currency: 'USD', + recurrence: 'hourly', +}; + +const FIXED_PROJECT = { + id: 'fixed-project-ref', + ref: 'fixed-project-ref', + organization_id: 'fixed-org', + organization_slug: 'fixed-org', + name: 'Fixture Project', + status: 'UNKNOWN', + created_at: '2026-01-01T00:00:00.000Z', + region: 'us-east-1', +}; + +const FIXED_BRANCH = { + id: 'fixed-branch', + name: 'develop', + project_ref: 'fixed-branch-ref', + parent_project_ref: 'fixed-project-ref', + is_default: false, + persistent: false, + status: 'CREATING_PROJECT' as const, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', +}; + +const account: AccountOperations = { + async listOrganizations() { + return [{ id: 'fixed-org', slug: 'fixed-org', name: 'Fixture Org' }]; + }, + async getOrganization() { + return { + id: 'fixed-org', + name: 'Fixture Org', + plan: 'pro', + allowed_release_channels: ['ga'], + opt_in_tags: [], + }; + }, + async listProjects() { + return [FIXED_PROJECT]; + }, + async getProject() { + return FIXED_PROJECT; + }, + async createProject() { + return FIXED_PROJECT; + }, + async pauseProject() {}, + async restoreProject() {}, + async getProjectCreationRate() { + return PROJECT_RATE; + }, +}; + +const branching: BranchingOperations = { + async listBranches() { + return []; + }, + async createBranch() { + return FIXED_BRANCH; + }, + async deleteBranch() {}, + async mergeBranch() {}, + async resetBranch() {}, + async rebaseBranch() {}, + async getBranchCreationRate() { + return BRANCH_RATE; + }, +}; + +type CreateProjectArgs = { + name: string; + region: string; + organization_id: string; +}; + +type CreateBranchArgs = { project_id: string; name: string }; + +/** + * Serves the real paid tools with the cost policy attached, exactly as a + * connection that also serves modern capable clients would. + */ +async function setupLegacyClient() { + const runtime = createElicitationRuntime({ + actorId: 'actor-1', + stateKey: 'legacy-bytes-continuation-key-long-enough', + formDeliveryAvailable: true, + }); + const capable = (ctx: Parameters[0]) => + runtime.availability(ctx).formElicitation; + + const accountTools = getAccountTools({ account }); + const branchingTools = getBranchingTools({ branching }); + + const tools: Record> = { + ...accountTools, + ...branchingTools, + confirm_cost: { + ...accountTools.confirm_cost, + policy: routeLegacyConfirmation({ capable }), + }, + create_project: { + ...accountTools.create_project, + policy: routeCostConfirmation({ + capable, + confirmed: runtime.policy( + 'create_project', + createCostConfirmationPolicy({ + action: 'create_project', + available: capable, + canonicalArguments: ({ name, region, organization_id }) => ({ + name, + region, + organization_id, + }), + subject: ({ name, organization_id }) => ({ + resourceName: name, + account: { type: 'organization', id: organization_id }, + }), + readRate: () => account.getProjectCreationRate('fixed-org'), + }) + ), + }), + }, + create_branch: { + ...branchingTools.create_branch, + policy: routeCostConfirmation({ + capable, + confirmed: runtime.policy( + 'create_branch', + createCostConfirmationPolicy({ + action: 'create_branch', + available: capable, + canonicalArguments: ({ project_id, name }) => ({ + project_id, + name, + }), + subject: ({ project_id, name }) => ({ + resourceName: name, + account: { type: 'parent_project', id: project_id }, + }), + readRate: ({ project_id }) => + branching.getBranchCreationRate(project_id), + }) + ), + }), + }, + }; + + const clientTransport = new StreamTransport(); + const serverTransport = new StreamTransport(); + clientTransport.readable.pipeTo(serverTransport.writable); + serverTransport.readable.pipeTo(clientTransport.writable); + + const server = createMcpServer({ + name: 'supabase', + version: '0.0.0', + requestState: runtime.requestState, + tools, + }); + const client = new Client( + { name: 'test-client', version: '1.0.0' }, + { capabilities: {} } + ); + + await server.connect(serverTransport); + await client.connect(clientTransport); + return client; +} + +async function entryOf(client: Client, name: string) { + const { tools } = await client.listTools(); + return tools.find((entry) => entry.name === name); +} + +/** Reads a confirmation id out of a legacy `confirm_cost` result. */ +function confirmationIdOf(result: { content?: unknown }): string { + const content = result.content; + if (!Array.isArray(content)) { + throw new Error('tool result carried no content'); + } + const [entry] = content; + if ( + entry === undefined || + typeof entry !== 'object' || + !('text' in entry) || + typeof entry.text !== 'string' + ) { + throw new Error('tool result content is not text'); + } + const parsed: unknown = JSON.parse(entry.text); + if ( + parsed === null || + typeof parsed !== 'object' || + !('confirmation_id' in parsed) || + typeof parsed.confirmation_id !== 'string' + ) { + throw new Error('tool result carried no confirmation id'); + } + return parsed.confirmation_id; +} + +test('a legacy caller keeps base discovery bytes for the paid tools', async () => { + const client = await setupLegacyClient(); + + expect(JSON.stringify(await entryOf(client, 'create_project'))).toBe( + BASE_CREATE_PROJECT_ENTRY + ); + expect(JSON.stringify(await entryOf(client, 'create_branch'))).toBe( + BASE_CREATE_BRANCH_ENTRY + ); + expect(JSON.stringify(await entryOf(client, 'confirm_cost'))).toBe( + BASE_CONFIRM_COST_ENTRY + ); +}); + +test('a legacy caller keeps base call bytes through the confirmation pair', async () => { + const client = await setupLegacyClient(); + + const projectConfirmation = await client.callTool({ + name: 'confirm_cost', + arguments: { type: 'project', recurrence: 'monthly', amount: 10 }, + }); + expect(JSON.stringify(projectConfirmation)).toBe( + BASE_CONFIRM_COST_PROJECT_RESULT + ); + + const confirmationId = confirmationIdOf(projectConfirmation); + + expect( + JSON.stringify( + await client.callTool({ + name: 'create_project', + arguments: { + name: 'Fixture Project', + region: 'us-east-1', + organization_id: 'fixed-org', + confirm_cost_id: confirmationId, + }, + }) + ) + ).toBe(BASE_CREATE_PROJECT_RESULT); + + const branchConfirmation = await client.callTool({ + name: 'confirm_cost', + arguments: { type: 'branch', recurrence: 'hourly', amount: 0.01344 }, + }); + const branchConfirmationId = confirmationIdOf(branchConfirmation); + + expect( + JSON.stringify( + await client.callTool({ + name: 'create_branch', + arguments: { + project_id: 'fixed-project-ref', + name: 'develop', + confirm_cost_id: branchConfirmationId, + }, + }) + ) + ).toBe(BASE_CREATE_BRANCH_RESULT); +}); diff --git a/packages/mcp-server-supabase/src/server.ts b/packages/mcp-server-supabase/src/server.ts index cbe7859e..c6a2fa2a 100644 --- a/packages/mcp-server-supabase/src/server.ts +++ b/packages/mcp-server-supabase/src/server.ts @@ -1,9 +1,13 @@ +import type { CallToolResult } from '@modelcontextprotocol/server'; import { createMcpServer, + type McpServerOptions, type Tool, type ToolCallCallback, + type ToolRequestContext, } from '@supabase/mcp-utils'; import packageJson from '../package.json' with { type: 'json' }; +import { createElicitationRuntime } from './elicitations/runtime.js'; import { createContentApiClient } from './content-api/index.js'; import type { SupabasePlatform } from './platform/types.js'; import { getAccountTools } from './tools/account-tools.js'; @@ -18,6 +22,54 @@ import { writeToolSet } from './tools/tool-schemas.js'; import { PLATFORM_INDEPENDENT_FEATURES, type FeatureGroup } from './types.js'; import { parseFeatureGroups } from './util.js'; +/** + * The dependencies a hosted deployment injects to serve cost confirmation. + * + * This is the whole supported surface, and it is deliberately smaller than + * what the private runtime accepts: continuation lifetime is capped by + * contract rather than configured, and the runtime's clock and correlation-id + * seams stay internal, because a caller that replaced them would break expiry + * classification and interaction correlation respectively. + */ +export type SupabaseElicitationOptions = { + /** + * Operator secret used to sign continuation state, at least 32 bytes. It + * never reaches a client: state is signed and readable, not encrypted. + */ + stateKey: string | Uint8Array; + + /** Authenticated approver every approval on this connection binds to. */ + actorId: string; + + /** + * Whether the serving path in front of this server can deliver a form. A + * path that cannot deliver one at all, such as deprecated stdio or classic + * hosted, is better served by omitting these options entirely. + */ + formDeliveryAvailable?: boolean; + + /** + * Connection-level form elicitation opt-out. + * + * For a modern connection whose caller opted out, pass these options with + * `optOut: true` rather than omitting them. Either way the tools stay on the + * legacy `confirm_cost` contract, but passing them keeps continuation state + * verified on this connection, so a flow that started before the opt-out + * still gets an actionable answer, and keeps an operator opt-out + * distinguishable from a client that never declared form support. Read the + * hosted URL-only opt-out on the initial leg and pass the result here. + */ + optOut?: boolean; + + /** + * Kill switch consulted immediately before protected execution. Returning a + * result blocks this attempt without invalidating signed state, so the same + * confirmation is redeemable once the gate reopens. Tools without a cost + * policy never reach it. + */ + gate?: (ctx: ToolRequestContext) => CallToolResult | null; +}; + const { version } = packageJson; export type SupabaseMcpServerOptions = { @@ -50,6 +102,26 @@ export type SupabaseMcpServerOptions = { */ features?: string[]; + /** + * Enables cost confirmation through form elicitation on this connection. + * + * Omit it for a serving path that cannot deliver a form at all, such as + * deprecated stdio or classic hosted. Every tool then keeps the surface it + * has today, policy-free and byte for byte. A read-only server keeps that + * surface too, because it creates nothing to confirm. + * + * A connection whose caller opted out passes these options with + * `optOut: true` rather than omitting them; see + * {@link SupabaseElicitationOptions.optOut} for why. + */ + elicitation?: SupabaseElicitationOptions; + + /** + * Callback for each pre-execution policy decision, carrying the allowlisted + * telemetry fields only. + */ + onToolPolicyCall?: McpServerOptions['onToolPolicyCall']; + /** * Callback for after a supabase tool is called. */ @@ -94,8 +166,26 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) { features, contentApiUrl = 'https://supabase.com/docs/api/graphql', onToolCall, + onToolPolicyCall, + elicitation: elicitationOptions, } = options; + // One runtime per connection, and only when the consumer says this + // connection serves form elicitation. Everything downstream keys off its + // presence, so a consumer that injects nothing gets a policy-free server. + const elicitation = + elicitationOptions === undefined + ? undefined + : // Mapped field by field, never spread: a knob the private runtime + // grows stays internal until this package decides to support it. + createElicitationRuntime({ + actorId: elicitationOptions.actorId, + stateKey: elicitationOptions.stateKey, + formDeliveryAvailable: elicitationOptions.formDeliveryAvailable, + optOut: elicitationOptions.optOut, + gate: elicitationOptions.gate, + }); + const contentApiClientPromise = createContentApiClient(contentApiUrl, { 'User-Agent': `supabase-mcp/${version}`, }); @@ -131,6 +221,8 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) { ), ]); }, + requestState: elicitation?.requestState, + onToolPolicyCall, onToolCall, tools: async () => { const contentApiClient = await contentApiClientPromise; @@ -151,7 +243,10 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) { } if (!projectId && account && enabledFeatures.has('account')) { - Object.assign(tools, getAccountTools({ account, readOnly })); + Object.assign( + tools, + getAccountTools({ account, readOnly, elicitation }) + ); } if (database && enabledFeatures.has('database')) { @@ -183,7 +278,7 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) { if (branching && enabledFeatures.has('branching')) { Object.assign( tools, - getBranchingTools({ branching, projectId, readOnly }) + getBranchingTools({ branching, projectId, readOnly, elicitation }) ); } diff --git a/packages/mcp-server-supabase/src/tools/account-tools.ts b/packages/mcp-server-supabase/src/tools/account-tools.ts index dc22843c..f456b01a 100644 --- a/packages/mcp-server-supabase/src/tools/account-tools.ts +++ b/packages/mcp-server-supabase/src/tools/account-tools.ts @@ -1,10 +1,14 @@ -import { tool } from '@supabase/mcp-utils'; +import { tool, type ToolRequestContext } from '@supabase/mcp-utils'; import { z } from 'zod/v4'; import type { ToolDefs } from './util.js'; +import type { ElicitationRuntime } from '../elicitations/runtime.js'; import type { AccountOperations } from '../platform/types.js'; import { organizationSchema, projectSchema } from '../platform/types.js'; import { assertRateStillApproved, + createCostConfirmationPolicy, + routeCostConfirmation, + routeLegacyConfirmation, type CostConfirmationResolution, } from '../policies/cost-confirmation.js'; import { legacyBranchCost, toCost } from '../pricing.js'; @@ -14,6 +18,12 @@ import { hashObject } from '../util.js'; type AccountToolsOptions = { account: AccountOperations; readOnly?: boolean; + /** + * Present only when this connection serves form elicitation. Its absence + * leaves every paid tool policy-free, which is what keeps a consumer that + * never injects it on the exact surface it has today. + */ + elicitation?: ElicitationRuntime; }; const listOrganizationsInputSchema = z.object({}); @@ -219,7 +229,40 @@ export const accountToolDefs = { }, } as const satisfies ToolDefs; -export function getAccountTools({ account, readOnly }: AccountToolsOptions) { +export function getAccountTools({ + account, + readOnly, + elicitation, +}: AccountToolsOptions) { + const capable = (ctx: ToolRequestContext) => + elicitation?.availability(ctx).formElicitation === true; + + const createProjectPolicy = + elicitation && + routeCostConfirmation({ + capable, + confirmed: elicitation.policy( + 'create_project', + createCostConfirmationPolicy>({ + action: 'create_project', + available: capable, + // The legacy token is deliberately absent: an approval binds to the + // project that was proposed, never to a token from the other lane. + canonicalArguments: ({ name, region, organization_id }) => ({ + name, + region, + organization_id, + }), + subject: ({ name, organization_id }) => ({ + resourceName: name, + account: { type: 'organization', id: organization_id }, + }), + readRate: ({ organization_id }) => + account.getProjectCreationRate(organization_id), + }) + ), + }); + return { list_organizations: tool({ ...accountToolDefs.list_organizations, @@ -263,6 +306,10 @@ export function getAccountTools({ account, readOnly }: AccountToolsOptions) { }), confirm_cost: tool({ ...accountToolDefs.confirm_cost, + // Hidden from a capable client's tool list, and still registered: a + // client that calls it by name is answered either way. + visible: (ctx) => !capable(ctx), + policy: elicitation && routeLegacyConfirmation({ capable }), execute: async (cost) => { return { confirmation_id: await hashObject(cost) }; }, @@ -273,6 +320,7 @@ export function getAccountTools({ account, readOnly }: AccountToolsOptions) { CostConfirmationResolution | undefined >({ ...accountToolDefs.create_project, + policy: createProjectPolicy, execute: async ( { name, region, organization_id, confirm_cost_id }, resolution diff --git a/packages/mcp-server-supabase/src/tools/branching-tools.ts b/packages/mcp-server-supabase/src/tools/branching-tools.ts index dc5aed59..513e27d5 100644 --- a/packages/mcp-server-supabase/src/tools/branching-tools.ts +++ b/packages/mcp-server-supabase/src/tools/branching-tools.ts @@ -1,9 +1,12 @@ -import { tool } from '@supabase/mcp-utils'; +import { tool, type ToolRequestContext } from '@supabase/mcp-utils'; import { z } from 'zod/v4'; +import type { ElicitationRuntime } from '../elicitations/runtime.js'; import type { BranchingOperations } from '../platform/types.js'; import { branchSchema } from '../platform/types.js'; import { assertRateStillApproved, + createCostConfirmationPolicy, + routeCostConfirmation, type CostConfirmationResolution, } from '../policies/cost-confirmation.js'; import { legacyBranchCost } from '../pricing.js'; @@ -14,6 +17,8 @@ type BranchingToolsOptions = { branching: BranchingOperations; projectId?: string; readOnly?: boolean; + /** Present only when this connection serves form elicitation. */ + elicitation?: ElicitationRuntime; }; const createBranchInputSchema = z.object({ @@ -159,8 +164,31 @@ export function getBranchingTools({ branching, projectId, readOnly, + elicitation, }: BranchingToolsOptions) { const project_id = projectId; + const capable = (ctx: ToolRequestContext) => + elicitation?.availability(ctx).formElicitation === true; + + const createBranchPolicy = + elicitation && + routeCostConfirmation({ + capable, + confirmed: elicitation.policy( + 'create_branch', + createCostConfirmationPolicy>({ + action: 'create_branch', + available: capable, + canonicalArguments: ({ project_id, name }) => ({ project_id, name }), + subject: ({ project_id, name }) => ({ + resourceName: name, + account: { type: 'parent_project', id: project_id }, + }), + readRate: ({ project_id }) => + branching.getBranchCreationRate(project_id), + }) + ), + }); return { create_branch: injectableTool< @@ -170,6 +198,7 @@ export function getBranchingTools({ CostConfirmationResolution | undefined >({ ...branchingToolDefs.create_branch, + policy: createBranchPolicy, inject: { project_id }, execute: async ({ project_id, name, confirm_cost_id }, resolution) => { if (readOnly) { From 1a84795fd33eb56eee4a5080ca4ceccae450e42b Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Mon, 24 Aug 2026 23:07:25 +0200 Subject: [PATCH 3/4] test: complete hosted cost policy contracts Completes the PR C-owned matrix and the explicit product text that goes with an accepted creation. The policy suite owns what a rate and an answer mean: an authoritative rate reaching the proposal with its currency and recurrence, a zero rate executing unprompted while still carrying the ceiling, consent read from the wire action whatever the response body says, decline and cancel staying distinct, an unanswered confirmation asking again, and the approved-ceiling comparison across amount, recurrence and currency. The integration suite drives the product rows through `createSupabaseMcpHandler`: accepted project and branch, declined, cancelled, zero rate, equal or lower final rate, higher rate, changed recurrence, changed currency, the rate read immediately before each creation call, a legacy token that cannot bypass the confirmation, capable discovery without `confirm_cost`, migration guidance on a direct call, and every surface that stays legacy, including a classic client that declares form support. It relies on PR B for state lifetime, expiry, continuation and repeated-state identity rather than repeating that matrix, and proves client labels are not read with one contract instead of a table. Accepted creations now render explicit text: what the client reported, the rate it reported against, and that the resource was created. A zero rate says instead that no confirmation was requested, because nothing was asked. The ceiling travels from execution to rendering through a per-result weak map, so nothing is added to the business output, and a legacy request skips the rendering hook entirely and keeps its single-encoded text. The packed platform consumer now drives a full hosted confirmation through the packed artifact, checks the confirmation carries the rate and no properties, and fails if the entry point ever exports a runtime, state, codec, policy or interaction symbol. Draft copy remains draft: the required facts have one owning test each, and the projection stays an empty slot pending Billing approval. --- .../src/elicitations.test.ts | 469 ++++++++++++++++++ .../src/policies/cost-confirmation.test.ts | 303 +++++++++++ .../src/policies/cost-confirmation.ts | 41 ++ .../src/tools/account-tools.ts | 23 +- .../src/tools/branching-tools.ts | 19 +- .../mcp-server-supabase/test/cost-platform.ts | 113 +++++ .../test/stdio.integration.ts | 13 + .../packed-platform-consumer/modern-call.mjs | 96 +++- .../packed-platform-consumer/types-check.ts | 48 +- 9 files changed, 1105 insertions(+), 20 deletions(-) create mode 100644 packages/mcp-server-supabase/src/elicitations.test.ts create mode 100644 packages/mcp-server-supabase/src/policies/cost-confirmation.test.ts create mode 100644 packages/mcp-server-supabase/test/cost-platform.ts diff --git a/packages/mcp-server-supabase/src/elicitations.test.ts b/packages/mcp-server-supabase/src/elicitations.test.ts new file mode 100644 index 00000000..6a35a817 --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations.test.ts @@ -0,0 +1,469 @@ +import { + Client, + StreamableHTTPClientTransport, +} from '@modelcontextprotocol/client'; +import type { + ClientCapabilities, + ElicitResult, +} from '@modelcontextprotocol/server'; +import { StreamTransport } from '@supabase/mcp-utils'; +import { describe, expect, test } from 'vitest'; + +import { + BRANCH_RATE, + createCostPlatform, + FIXED_PROJECT, + PROJECT_RATE, +} from '../test/cost-platform.js'; +import type { CreationRate } from './platform/types.js'; +import { createSupabaseMcpServer } from './server.js'; +import { createSupabaseMcpHandler } from './transports/http.js'; + +const MODERN_PROTOCOL_VERSION = '2026-07-28'; +const MCP_ENDPOINT = new URL('https://mcp.test'); +const STATE_KEY = 'cost-policy-continuation-key-long-enough'; +const ACTOR_ID = 'approver-1'; + +type SetupOptions = { + /** Client capabilities. `{}` is a client that declares nothing. */ + capabilities?: ClientCapabilities; + clientName?: string; + optOut?: boolean; + readOnly?: boolean; + answers?: ElicitResult[]; +}; + +/** A modern hosted connection whose serving path can deliver a form. */ +async function setupModern(options: SetupOptions = {}) { + const cost = createCostPlatform(); + const elicited: string[] = []; + const handler = createSupabaseMcpHandler({ + platform: cost.platform, + features: ['account', 'branching'], + readOnly: options.readOnly, + elicitation: { + actorId: ACTOR_ID, + stateKey: STATE_KEY, + formDeliveryAvailable: true, + optOut: options.optOut, + }, + }); + + const transport = new StreamableHTTPClientTransport(MCP_ENDPOINT, { + fetch: async (url, init) => handler.fetch(new Request(url, init)), + }); + const capabilities = options.capabilities ?? { elicitation: {} }; + const client = new Client( + { name: options.clientName ?? 'cost-policy-test-client', version: '1.0.0' }, + { + capabilities, + versionNegotiation: { mode: { pin: MODERN_PROTOCOL_VERSION } }, + } + ); + + const answers = options.answers ?? [{ action: 'accept' }]; + // A client that declares no elicitation cannot register a handler for one, + // which is the same reason it must never be sent a form. + if (capabilities.elicitation !== undefined) { + client.setRequestHandler('elicitation/create', async (request) => { + const { message } = request.params; + elicited.push(typeof message === 'string' ? message : ''); + const answer = answers.shift(); + if (answer === undefined) { + throw new Error('no elicitation answer left'); + } + return answer; + }); + } + + await client.connect(transport); + + return { client, elicited, ...cost }; +} + +/** + * A classic hosted connection: the same server options, over a transport that + * carries no per-request envelope. + */ +async function setupClassic(capabilities: ClientCapabilities) { + const cost = createCostPlatform(); + const clientTransport = new StreamTransport(); + const serverTransport = new StreamTransport(); + clientTransport.readable.pipeTo(serverTransport.writable); + serverTransport.readable.pipeTo(clientTransport.writable); + + const server = createSupabaseMcpServer({ + platform: cost.platform, + features: ['account', 'branching'], + elicitation: { + actorId: ACTOR_ID, + stateKey: STATE_KEY, + formDeliveryAvailable: true, + }, + }); + const client = new Client( + { name: 'classic-client', version: '1.0.0' }, + { capabilities } + ); + let elicits = 0; + client.setRequestHandler('elicitation/create', async () => { + elicits += 1; + return { action: 'accept' }; + }); + + await server.connect(serverTransport); + await client.connect(clientTransport); + + return { client, elicitCount: () => elicits, ...cost }; +} + +function textOf(result: { content?: unknown }): string { + const content = result.content; + if (!Array.isArray(content)) { + return ''; + } + return content + .map((entry) => + entry !== null && + typeof entry === 'object' && + 'text' in entry && + typeof entry.text === 'string' + ? entry.text + : '' + ) + .join(''); +} + +const PROJECT_ARGS = { + name: 'Fixture Project', + region: 'us-east-1', + organization_id: 'fixed-org', +}; + +const BRANCH_ARGS = { project_id: 'fixed-project-ref', name: 'develop' }; + +describe('modern capable creation', () => { + test('an accepted project is created with its business output unchanged', async () => { + const { client, elicited } = await setupModern(); + + const result = await client.callTool({ + name: 'create_project', + arguments: PROJECT_ARGS, + }); + + expect(elicited).toHaveLength(1); + expect(elicited[0]).toContain( + `${PROJECT_RATE.amount} ${PROJECT_RATE.currency}` + ); + expect(result.structuredContent).toStrictEqual(FIXED_PROJECT); + // Explicit text beside the unchanged business output: what the client + // reported, and what the server did about it. + expect(textOf(result)).toContain('The client reported'); + expect(textOf(result)).toContain('10 USD per month'); + expect(textOf(result)).toContain('"Fixture Project" was created'); + }); + + test('an accepted branch is created', async () => { + const { client, elicited, calls } = await setupModern(); + + const result = await client.callTool({ + name: 'create_branch', + arguments: BRANCH_ARGS, + }); + + expect(elicited[0]).toContain( + `${BRANCH_RATE.amount} ${BRANCH_RATE.currency}` + ); + expect(elicited[0]).toContain('per hour'); + expect(calls).toContain('create_branch'); + expect(textOf(result)).toContain('The client reported'); + expect(textOf(result)).toContain('0.01344 USD per hour'); + expect(textOf(result)).toContain('"develop" was created'); + expect(result.structuredContent).toMatchObject({ name: 'develop' }); + }); + + test('a declined project reports the client outcome and creates nothing', async () => { + const { client, calls } = await setupModern({ + answers: [{ action: 'decline' }], + }); + + const result = await client.callTool({ + name: 'create_project', + arguments: PROJECT_ARGS, + }); + + expect(result.structuredContent).toStrictEqual({ status: 'declined' }); + expect(textOf(result)).toContain('The client reported'); + expect(textOf(result)).toContain('no project was created'); + expect(calls).not.toContain('create_project'); + }); + + test('a cancelled project stays distinct from a declined one', async () => { + const { client, calls } = await setupModern({ + answers: [{ action: 'cancel' }], + }); + + const result = await client.callTool({ + name: 'create_project', + arguments: PROJECT_ARGS, + }); + + expect(result.structuredContent).toStrictEqual({ status: 'cancelled' }); + expect(textOf(result)).toContain('dismissed'); + expect(calls).not.toContain('create_project'); + }); + + test('a zero authoritative rate creates without asking, and is still guarded', async () => { + const { client, calls, elicited, projectRates } = await setupModern(); + const free: CreationRate = { ...PROJECT_RATE, amount: 0 }; + projectRates.push(free, free); + + const result = await client.callTool({ + name: 'create_project', + arguments: PROJECT_ARGS, + }); + + expect(elicited).toHaveLength(0); + expect(result.structuredContent).toStrictEqual(FIXED_PROJECT); + // Nothing was asked, so nothing is reported as accepted. + expect(textOf(result)).toContain('no confirmation was requested'); + expect(textOf(result)).not.toContain('client reported'); + expect(textOf(result)).toContain('was created'); + expect(calls).toStrictEqual([ + 'read_project_rate', + 'read_project_rate', + 'create_project', + ]); + }); +}); + +describe('the final authoritative check', () => { + test('the rate is read immediately before the creation call', async () => { + const { client, calls } = await setupModern(); + + await client.callTool({ name: 'create_project', arguments: PROJECT_ARGS }); + + // The proposal read, then the read that guards the side effect, then the + // side effect. Nothing sits between the last two. + expect(calls).toStrictEqual([ + 'read_project_rate', + 'read_project_rate', + 'create_project', + ]); + }); + + test('an equal or lower final rate proceeds', async () => { + const { client, calls, projectRates } = await setupModern(); + projectRates.push(PROJECT_RATE, { ...PROJECT_RATE, amount: 4 }); + + const result = await client.callTool({ + name: 'create_project', + arguments: PROJECT_ARGS, + }); + + expect(result.isError).not.toBe(true); + expect(calls).toContain('create_project'); + }); + + test.each<[string, CreationRate]>([ + ['a higher amount', { ...PROJECT_RATE, amount: 11 }], + [ + 'a changed recurrence', + { ...PROJECT_RATE, amount: 1, recurrence: 'hourly' }, + ], + ['a changed currency', { ...PROJECT_RATE, amount: 1, currency: 'EUR' }], + ])( + '%s creates nothing and reports a stale approval', + async (_case, final) => { + const { client, calls, projectRates } = await setupModern(); + projectRates.push(PROJECT_RATE, final); + + const result = await client.callTool({ + name: 'create_project', + arguments: PROJECT_ARGS, + }); + + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('approved_rate_stale'); + expect(calls).not.toContain('create_project'); + } + ); + + test('a branch approval is guarded by the branch rate', async () => { + const { client, calls, branchRates } = await setupModern(); + branchRates.push(BRANCH_RATE, { ...BRANCH_RATE, amount: 0.02 }); + + const result = await client.callTool({ + name: 'create_branch', + arguments: BRANCH_ARGS, + }); + + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('approved_rate_stale'); + expect(calls).not.toContain('create_branch'); + }); +}); + +describe('capable discovery and the retired token', () => { + test('discovery omits confirm_cost and the legacy token', async () => { + const { client } = await setupModern(); + + const { tools } = await client.listTools(); + const createProject = tools.find( + (entry) => entry.name === 'create_project' + ); + + expect(tools.map((entry) => entry.name)).not.toContain('confirm_cost'); + expect( + Object.keys(createProject?.inputSchema.properties ?? {}) + ).toStrictEqual(['name', 'region', 'organization_id']); + expect(createProject?.outputSchema).toBeDefined(); + }); + + test('a direct call to confirm_cost answers with migration guidance', async () => { + const { client } = await setupModern(); + + const result = await client.callTool({ + name: 'confirm_cost', + arguments: { type: 'project', recurrence: 'monthly', amount: 10 }, + }); + + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('create_project'); + expect(textOf(result)).not.toContain('confirmation_id'); + }); + + test('a supplied legacy token is ignored and cannot bypass the confirmation', async () => { + const { client, elicited, calls } = await setupModern({ + answers: [{ action: 'decline' }], + }); + + const result = await client.callTool({ + name: 'create_project', + arguments: { + ...PROJECT_ARGS, + confirm_cost_id: 'a-token-from-the-other-lane', + }, + }); + + // The token neither bypasses the question nor fails the call: it is + // dropped before anything binds to it. + expect(elicited).toHaveLength(1); + expect(result.structuredContent).toStrictEqual({ status: 'declined' }); + expect(calls).not.toContain('create_project'); + }); + + test('the flow does not read the client label', async () => { + // One contract instead of a compatibility table: a client whose name + // matches nothing this package knows still gets the form lane, because the + // decision reads capabilities and the serving path only. + const { client, elicited } = await setupModern({ + clientName: 'some-client-nobody-listed', + }); + + const result = await client.callTool({ + name: 'create_project', + arguments: PROJECT_ARGS, + }); + + expect(elicited).toHaveLength(1); + expect(result.structuredContent).toStrictEqual(FIXED_PROJECT); + }); +}); + +describe('read-only servers', () => { + test('a call fails read-only without reading a rate or asking', async () => { + const { client, elicited, calls } = await setupModern({ readOnly: true }); + + // The tool is policy-free here, so it takes the legacy shape it has + // always had in read-only mode, token included. + const result = await client.callTool({ + name: 'create_project', + arguments: { ...PROJECT_ARGS, confirm_cost_id: 'any-token' }, + }); + + // Policy resolution runs before a tool's own checks, so a policy here + // would price and prompt for a creation that cannot happen. + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('read-only mode'); + expect(elicited).toHaveLength(0); + expect(calls).toStrictEqual([]); + }); +}); + +describe('surfaces that stay on the legacy contract', () => { + test('a modern client that declares no elicitation keeps confirm_cost', async () => { + const { client } = await setupModern({ capabilities: {} }); + + const { tools } = await client.listTools(); + const createProject = tools.find( + (entry) => entry.name === 'create_project' + ); + + expect(tools.map((entry) => entry.name)).toContain('confirm_cost'); + expect(createProject?.inputSchema.required).toContain('confirm_cost_id'); + expect(createProject?.outputSchema).toBeUndefined(); + }); + + test('a URL-only elicitation declaration is not form support', async () => { + const { client } = await setupModern({ + capabilities: { elicitation: { url: {} } }, + }); + + const { tools } = await client.listTools(); + expect(tools.map((entry) => entry.name)).toContain('confirm_cost'); + }); + + test('an opted-out connection keeps confirm_cost for a capable client', async () => { + const { client } = await setupModern({ optOut: true }); + + const { tools } = await client.listTools(); + const createProject = tools.find( + (entry) => entry.name === 'create_project' + ); + + expect(tools.map((entry) => entry.name)).toContain('confirm_cost'); + expect(createProject?.inputSchema.required).toContain('confirm_cost_id'); + expect(createProject?.outputSchema).toBeUndefined(); + }); + + test('a classic client declaring form still gets confirm_cost and no form', async () => { + const { client, elicitCount } = await setupClassic({ + elicitation: { form: {} }, + }); + + const { tools } = await client.listTools(); + const createProject = tools.find( + (entry) => entry.name === 'create_project' + ); + + expect(tools.map((entry) => entry.name)).toContain('confirm_cost'); + expect(createProject?.inputSchema.required).toContain('confirm_cost_id'); + expect(elicitCount()).toBe(0); + }); + + test('the legacy lane still creates through the confirmation pair', async () => { + const { client, calls } = await setupModern({ capabilities: {} }); + + const cost = await client.callTool({ + name: 'get_cost', + arguments: { type: 'project', organization_id: 'fixed-org' }, + }); + const confirmation = await client.callTool({ + name: 'confirm_cost', + arguments: JSON.parse(textOf(cost)), + }); + const result = await client.callTool({ + name: 'create_project', + arguments: { + ...PROJECT_ARGS, + confirm_cost_id: JSON.parse(textOf(confirmation)).confirmation_id, + }, + }); + + expect(result.isError).not.toBe(true); + expect(JSON.parse(textOf(result))).toStrictEqual(FIXED_PROJECT); + expect(result.structuredContent).toBeUndefined(); + expect(calls).toContain('create_project'); + }); +}); diff --git a/packages/mcp-server-supabase/src/policies/cost-confirmation.test.ts b/packages/mcp-server-supabase/src/policies/cost-confirmation.test.ts new file mode 100644 index 00000000..b6f8d8f4 --- /dev/null +++ b/packages/mcp-server-supabase/src/policies/cost-confirmation.test.ts @@ -0,0 +1,303 @@ +import type { InputResponseView } from '@modelcontextprotocol/server'; +import { describe, expect, test, vi } from 'vitest'; + +import type { CreationRate } from '../platform/types.js'; +import { + APPROVED_RATE_STALE, + assertRateStillApproved, + costConfirmationMessage, + createCostConfirmationPolicy, + creationOutcomeMessage, + POLICY_VERSION, + type CostConfirmationProposal, + type CostConfirmationPolicyOptions, +} from './cost-confirmation.js'; + +type Args = { name: string; organization_id: string }; + +const BILLABLE: CreationRate = { + amount: 10, + currency: 'USD', + recurrence: 'monthly', +}; + +const PROPOSAL: CostConfirmationProposal = { + action: 'create_project', + resourceName: 'demo', + account: { type: 'organization', id: 'acme' }, + rate: BILLABLE, +}; + +function policyFor(rate: CreationRate) { + const readRate = vi.fn(async () => rate); + const options: CostConfirmationPolicyOptions = { + action: 'create_project', + available: () => true, + canonicalArguments: ({ name, organization_id }) => ({ + name, + organization_id, + }), + subject: ({ name, organization_id }) => ({ + resourceName: name, + account: { type: 'organization', id: organization_id }, + }), + readRate, + }; + + return { policy: createCostConfirmationPolicy(options), readRate }; +} + +/** One embedded request, so its key is whatever the policy asked under. */ +function onlyRequest(proposal: CostConfirmationProposal) { + const requests = createCostConfirmationPolicy({ + action: proposal.action, + available: () => true, + canonicalArguments: (args) => args, + subject: () => ({ + resourceName: proposal.resourceName, + account: proposal.account, + }), + readRate: async () => proposal.rate, + }).inputRequests(proposal); + + const entries = Object.entries(requests); + expect(entries).toHaveLength(1); + return entries[0]!; +} + +async function answerWith( + proposal: CostConfirmationProposal, + answer: InputResponseView +) { + const [key] = onlyRequest(proposal); + const { policy } = policyFor(proposal.rate); + return policy.resolve(proposal, { [key]: answer }); +} + +/** Narrows one embedded request down to the schema it asked with. */ +function requestedSchemaOf(request: unknown): { + properties: Record; + required?: unknown; +} { + if ( + request === null || + typeof request !== 'object' || + !('params' in request) || + request.params === null || + typeof request.params !== 'object' || + !('requestedSchema' in request.params) + ) { + throw new Error('embedded request carried no requested schema'); + } + + const schema = request.params.requestedSchema; + if ( + schema === null || + typeof schema !== 'object' || + !('properties' in schema) || + schema.properties === null || + typeof schema.properties !== 'object' + ) { + throw new Error('requested schema carried no properties object'); + } + + return { + properties: schema.properties as Record, + required: 'required' in schema ? schema.required : undefined, + }; +} + +describe('authoritative rates', () => { + test('an authoritative rate reaches the proposal with its currency and recurrence', async () => { + const hourly: CreationRate = { + amount: 0.01344, + currency: 'USD', + recurrence: 'hourly', + }; + const { policy, readRate } = policyFor(hourly); + + const preparation = await policy.prepare({ + name: 'demo', + organization_id: 'acme', + }); + + expect(readRate).toHaveBeenCalledTimes(1); + expect(preparation).toStrictEqual({ + type: 'elicit', + proposal: { + action: 'create_project', + resourceName: 'demo', + account: { type: 'organization', id: 'acme' }, + rate: hourly, + }, + }); + }); + + test('a zero authoritative rate executes without asking, and still carries the ceiling', async () => { + const free: CreationRate = { + amount: 0, + currency: 'USD', + recurrence: 'monthly', + }; + const { policy } = policyFor(free); + + // The ceiling travels into execution even though nothing was asked, which + // is what keeps the check before creation meaningful on this lane. + expect( + await policy.prepare({ name: 'demo', organization_id: 'acme' }) + ).toStrictEqual({ + type: 'execute', + resolution: { maximumCreationRate: free }, + }); + }); +}); + +describe('action-only consent', () => { + test('the confirmation asks for no properties', () => { + const [, request] = onlyRequest(PROPOSAL); + + // A property-less schema is the whole mechanism: with no field to fill in, + // no response content can stand in for the caller's answer. + expect(request).toMatchObject({ + method: 'elicitation/create', + params: { + mode: 'form', + requestedSchema: { type: 'object', properties: {} }, + }, + }); + + const schema = requestedSchemaOf(request); + expect(Object.keys(schema.properties)).toEqual([]); + expect(schema.required).toBeUndefined(); + }); + + test('accept grants consent even when the response body says otherwise', async () => { + expect( + await answerWith(PROPOSAL, { + kind: 'elicit', + action: 'accept', + content: { confirm: false, approved: 'no' }, + }) + ).toStrictEqual({ + type: 'execute', + resolution: { maximumCreationRate: BILLABLE }, + }); + }); + + test('decline and cancel never grant consent, and stay distinct', async () => { + const declined = await answerWith(PROPOSAL, { + kind: 'elicit', + action: 'decline', + content: { confirm: true }, + }); + const cancelled = await answerWith(PROPOSAL, { + kind: 'elicit', + action: 'cancel', + content: { confirm: true }, + }); + + expect(declined.type).toBe('declined'); + expect(cancelled.type).toBe('cancelled'); + }); + + test('an unanswered confirmation asks again instead of assuming', async () => { + expect(await answerWith(PROPOSAL, { kind: 'missing' })).toStrictEqual({ + type: 'reissue', + }); + }); + + test('the policy version is 2, bound into every proposal it signs', () => { + const { policy } = policyFor(BILLABLE); + + // Version 1 read consent out of the response body. The runtime rejects a + // version it does not own before interpreting any response, so this number + // is what keeps a v1 answer from authorizing a v2 execution and back. + expect(POLICY_VERSION).toBe(2); + expect(policy.version).toBe(POLICY_VERSION); + }); +}); + +describe('draft copy', () => { + // Swap this test for one approved-copy contract when Design and PM sign off + // (root gate M1). It pins the facts, never the wording around them. + test('the confirmation states the facts a caller decides on', () => { + const message = costConfirmationMessage(PROPOSAL); + + expect(message).toContain('project'); + expect(message).toContain('"demo"'); + expect(message).toContain('acme'); + expect(message).toContain('10 USD'); + expect(message).toContain('per month'); + expect(message).toContain('recurs until'); + expect(message).toContain('Accept'); + expect(message).toContain('decline'); + }); + + test('the projection slot stays empty until Billing approves a convention', () => { + // Root gate M2. A total over time needs an hours-per-month convention this + // package must not invent, so the message says the rate and its interval + // and stops there. No hours constant appears anywhere in it. + const hourly = costConfirmationMessage({ + ...PROPOSAL, + action: 'create_branch', + account: { type: 'parent_project', id: 'parent-ref' }, + rate: { amount: 0.01344, currency: 'USD', recurrence: 'hourly' }, + }); + + expect(hourly).toContain('0.01344 USD per hour'); + expect(hourly).not.toMatch(/\b(720|730|744)\b/); + expect(hourly).not.toMatch(/per month/); + }); + + test('a creation reports what the client said, or that nothing was asked', () => { + const accepted = creationOutcomeMessage('create_project', 'demo', BILLABLE); + const unprompted = creationOutcomeMessage('create_branch', 'develop', { + amount: 0, + currency: 'USD', + recurrence: 'hourly', + }); + + // Client-reported, never a claim that a person saw the prompt. + expect(accepted).toContain('The client reported'); + expect(accepted).toContain('was created'); + // Nothing was asked on a zero rate, so nothing may be reported as accepted. + expect(unprompted).not.toContain('client reported'); + expect(unprompted).toContain('no confirmation was requested'); + expect(unprompted).toContain('was created'); + }); +}); + +describe('approved ceiling', () => { + const approved: CreationRate = { + amount: 10, + currency: 'USD', + recurrence: 'monthly', + }; + + test('an equal or lower rate proceeds', () => { + expect(() => assertRateStillApproved(approved, approved)).not.toThrow(); + expect(() => + assertRateStillApproved({ ...approved, amount: 4 }, approved) + ).not.toThrow(); + }); + + test('a higher rate, a changed recurrence, or a changed currency does not', () => { + // A smaller number under a different interval or currency is not a lower + // price, so neither may spend an approval. + for (const stale of [ + { ...approved, amount: 11 }, + { ...approved, amount: 1, recurrence: 'hourly' as const }, + { ...approved, amount: 1, currency: 'EUR' }, + ]) { + expect(() => assertRateStillApproved(stale, approved)).toThrowError( + new RegExp(APPROVED_RATE_STALE) + ); + } + }); + + test('the refusal states that nothing was created', () => { + expect(() => + assertRateStillApproved({ ...approved, amount: 11 }, approved) + ).toThrowError(/nothing was created/); + }); +}); diff --git a/packages/mcp-server-supabase/src/policies/cost-confirmation.ts b/packages/mcp-server-supabase/src/policies/cost-confirmation.ts index 5a9c1c85..ea63ba7a 100644 --- a/packages/mcp-server-supabase/src/policies/cost-confirmation.ts +++ b/packages/mcp-server-supabase/src/policies/cost-confirmation.ts @@ -192,6 +192,47 @@ export function creationOutcomeMessage( return `The client reported that ${rateStatement(approved)} was accepted. The ${resource} "${resourceName}" was created.`; } +export type CreationOutcomeText = { + /** Notes the ceiling one creation executed under, keyed by its own result. */ + record( + result: object, + resolution: CostConfirmationResolution | undefined + ): void; + /** Renders that creation's text. */ + render(result: object, resourceName: string): string; +}; + +/** + * Carries the approved ceiling from a creation to the text rendered for it. + * + * Result text is rendered by a hook that receives only the business output, so + * the rate that decided the wording travels here instead, keyed by the result + * object itself. Nothing is added to that object, so what reaches the wire is + * unchanged, and a request that is not normalized never renders text at all. + */ +export function createCreationOutcomeText( + action: CostConfirmationSubject['action'] +): CreationOutcomeText { + const approved = new WeakMap(); + + return { + record(result, resolution) { + if (resolution !== undefined) { + approved.set(result, resolution.maximumCreationRate); + } + }, + + render(result, resourceName) { + const ceiling = approved.get(result); + // No ceiling means this creation did not come through the policy, so it + // keeps the single-encoded rendering every caller has always received. + return ceiling === undefined + ? JSON.stringify(result) + : creationOutcomeMessage(action, resourceName, ceiling); + }, + }; +} + export type CostConfirmationPolicyOptions = { action: CostConfirmationSubject['action']; /** Whether this request can carry the confirmation at all. */ diff --git a/packages/mcp-server-supabase/src/tools/account-tools.ts b/packages/mcp-server-supabase/src/tools/account-tools.ts index f456b01a..e3c15c96 100644 --- a/packages/mcp-server-supabase/src/tools/account-tools.ts +++ b/packages/mcp-server-supabase/src/tools/account-tools.ts @@ -7,6 +7,7 @@ import { organizationSchema, projectSchema } from '../platform/types.js'; import { assertRateStillApproved, createCostConfirmationPolicy, + createCreationOutcomeText, routeCostConfirmation, routeLegacyConfirmation, type CostConfirmationResolution, @@ -234,14 +235,19 @@ export function getAccountTools({ readOnly, elicitation, }: AccountToolsOptions) { + // A read-only server creates nothing, so there is nothing to confirm. The + // paid tools stay policy-free and answer a direct call with the read-only + // error they always have, instead of reading a rate and prompting first. + const costConfirmation = readOnly === true ? undefined : elicitation; const capable = (ctx: ToolRequestContext) => - elicitation?.availability(ctx).formElicitation === true; + costConfirmation?.availability(ctx).formElicitation === true; + const projectOutcome = createCreationOutcomeText('create_project'); const createProjectPolicy = - elicitation && + costConfirmation && routeCostConfirmation({ capable, - confirmed: elicitation.policy( + confirmed: costConfirmation.policy( 'create_project', createCostConfirmationPolicy>({ action: 'create_project', @@ -309,7 +315,7 @@ export function getAccountTools({ // Hidden from a capable client's tool list, and still registered: a // client that calls it by name is answered either way. visible: (ctx) => !capable(ctx), - policy: elicitation && routeLegacyConfirmation({ capable }), + policy: costConfirmation && routeLegacyConfirmation({ capable }), execute: async (cost) => { return { confirmation_id: await hashObject(cost) }; }, @@ -321,6 +327,9 @@ export function getAccountTools({ >({ ...accountToolDefs.create_project, policy: createProjectPolicy, + // Rendered only for a request the policy normalized. A legacy request + // skips this hook entirely and keeps its single-encoded text. + formatResult: (project) => projectOutcome.render(project, project.name), execute: async ( { name, region, organization_id, confirm_cost_id }, resolution @@ -347,11 +356,15 @@ export function getAccountTools({ ); } - return await account.createProject({ + const project = await account.createProject({ name, region, organization_id, }); + + projectOutcome.record(project, resolution); + + return project; }, }), pause_project: tool({ diff --git a/packages/mcp-server-supabase/src/tools/branching-tools.ts b/packages/mcp-server-supabase/src/tools/branching-tools.ts index 513e27d5..6eb23170 100644 --- a/packages/mcp-server-supabase/src/tools/branching-tools.ts +++ b/packages/mcp-server-supabase/src/tools/branching-tools.ts @@ -6,6 +6,7 @@ import { branchSchema } from '../platform/types.js'; import { assertRateStillApproved, createCostConfirmationPolicy, + createCreationOutcomeText, routeCostConfirmation, type CostConfirmationResolution, } from '../policies/cost-confirmation.js'; @@ -167,14 +168,17 @@ export function getBranchingTools({ elicitation, }: BranchingToolsOptions) { const project_id = projectId; + // A read-only server creates nothing, so there is nothing to confirm. + const costConfirmation = readOnly === true ? undefined : elicitation; const capable = (ctx: ToolRequestContext) => - elicitation?.availability(ctx).formElicitation === true; + costConfirmation?.availability(ctx).formElicitation === true; + const branchOutcome = createCreationOutcomeText('create_branch'); const createBranchPolicy = - elicitation && + costConfirmation && routeCostConfirmation({ capable, - confirmed: elicitation.policy( + confirmed: costConfirmation.policy( 'create_branch', createCostConfirmationPolicy>({ action: 'create_branch', @@ -199,6 +203,9 @@ export function getBranchingTools({ >({ ...branchingToolDefs.create_branch, policy: createBranchPolicy, + // Rendered only for a request the policy normalized. A legacy request + // skips this hook entirely and keeps its single-encoded text. + formatResult: (branch) => branchOutcome.render(branch, branch.name), inject: { project_id }, execute: async ({ project_id, name, confirm_cost_id }, resolution) => { if (readOnly) { @@ -222,7 +229,11 @@ export function getBranchingTools({ ); } - return await branching.createBranch(project_id, { name }); + const branch = await branching.createBranch(project_id, { name }); + + branchOutcome.record(branch, resolution); + + return branch; }, }), list_branches: injectableTool({ diff --git a/packages/mcp-server-supabase/test/cost-platform.ts b/packages/mcp-server-supabase/test/cost-platform.ts new file mode 100644 index 00000000..e51aea0b --- /dev/null +++ b/packages/mcp-server-supabase/test/cost-platform.ts @@ -0,0 +1,113 @@ +import type { + AccountOperations, + BranchingOperations, + CreationRate, + SupabasePlatform, +} from '../src/platform/types.js'; + +/** + * A platform whose every value is fixed, for tests that pin exact bytes or the + * order operations run in. Nothing here is random, so a result is reproducible + * across runs and comparable against a measured baseline. + */ + +export const PROJECT_RATE: CreationRate = { + amount: 10, + currency: 'USD', + recurrence: 'monthly', +}; + +export const BRANCH_RATE: CreationRate = { + amount: 0.01344, + currency: 'USD', + recurrence: 'hourly', +}; + +export const FIXED_PROJECT = { + id: 'fixed-project-ref', + ref: 'fixed-project-ref', + organization_id: 'fixed-org', + organization_slug: 'fixed-org', + name: 'Fixture Project', + status: 'UNKNOWN', + created_at: '2026-01-01T00:00:00.000Z', + region: 'us-east-1', +}; + +export const FIXED_BRANCH = { + id: 'fixed-branch', + name: 'develop', + project_ref: 'fixed-branch-ref', + parent_project_ref: 'fixed-project-ref', + is_default: false, + persistent: false, + status: 'CREATING_PROJECT' as const, + created_at: '2026-01-01T00:00:00.000Z', + updated_at: '2026-01-01T00:00:00.000Z', +}; + +export type CostPlatform = { + platform: SupabasePlatform; + /** Rate reads and creations, in the order they happened. */ + calls: string[]; + /** Rates the next reads return, one per read, before the default applies. */ + projectRates: CreationRate[]; + branchRates: CreationRate[]; +}; + +export function createCostPlatform(): CostPlatform { + const calls: string[] = []; + const projectRates: CreationRate[] = []; + const branchRates: CreationRate[] = []; + + const account: AccountOperations = { + async listOrganizations() { + return [{ id: 'fixed-org', slug: 'fixed-org', name: 'Fixture Org' }]; + }, + async getOrganization() { + return { + id: 'fixed-org', + name: 'Fixture Org', + plan: 'pro', + allowed_release_channels: ['ga'], + opt_in_tags: [], + }; + }, + async listProjects() { + return [FIXED_PROJECT]; + }, + async getProject() { + return FIXED_PROJECT; + }, + async createProject() { + calls.push('create_project'); + return FIXED_PROJECT; + }, + async pauseProject() {}, + async restoreProject() {}, + async getProjectCreationRate() { + calls.push('read_project_rate'); + return projectRates.shift() ?? PROJECT_RATE; + }, + }; + + const branching: BranchingOperations = { + async listBranches() { + return []; + }, + async createBranch() { + calls.push('create_branch'); + return FIXED_BRANCH; + }, + async deleteBranch() {}, + async mergeBranch() {}, + async resetBranch() {}, + async rebaseBranch() {}, + async getBranchCreationRate() { + calls.push('read_branch_rate'); + return branchRates.shift() ?? BRANCH_RATE; + }, + }; + + return { platform: { account, branching }, calls, projectRates, branchRates }; +} diff --git a/packages/mcp-server-supabase/test/stdio.integration.ts b/packages/mcp-server-supabase/test/stdio.integration.ts index 2aaf0bb6..56ea0d38 100644 --- a/packages/mcp-server-supabase/test/stdio.integration.ts +++ b/packages/mcp-server-supabase/test/stdio.integration.ts @@ -329,6 +329,19 @@ describe('stdio', () => { }, ]); expect(contentApiStub.hits.length).toBeGreaterThan(0); + + // Deprecated stdio stays legacy-only: nothing injects form delivery + // here, so the paid tools keep the confirmation token they always + // required and advertise no structured output. + const createProject = tools.find( + (tool) => tool.name === 'create_project' + ); + const createBranch = tools.find((tool) => tool.name === 'create_branch'); + + for (const tool of [createProject, createBranch]) { + expect(tool?.inputSchema.required).toContain('confirm_cost_id'); + expect(tool?.outputSchema).toBeUndefined(); + } } finally { await client.close(); } diff --git a/scripts/fixtures/packed-platform-consumer/modern-call.mjs b/scripts/fixtures/packed-platform-consumer/modern-call.mjs index 85cff15d..6923ec3f 100644 --- a/scripts/fixtures/packed-platform-consumer/modern-call.mjs +++ b/scripts/fixtures/packed-platform-consumer/modern-call.mjs @@ -2,18 +2,30 @@ import { Client, StreamableHTTPClientTransport, } from '@modelcontextprotocol/client'; +import * as packageExports from '@supabase/mcp-server-supabase'; import { createSupabaseMcpHandler } from '@supabase/mcp-server-supabase'; // https://blog.modelcontextprotocol.io/posts/2026-07-28-release-candidate/ const MODERN_PROTOCOL_VERSION = '2026-07-28'; -// Stubbed `account` operations only run on a tool call. The account feature -// registers real zod-built schemas, and the tools/list checks below verify -// that create_project keeps its required properties with Platform's zod pin. -// `docs` stays out: its tool description lazily calls supabase.com, and this -// exchange must never leave the process. +// Stubbed `account` operations, real where this exchange needs them: the +// account feature registers real zod-built schemas, and the checks below drive +// a confirmed creation through them with Platform's zod pin. `docs` stays out: +// its tool description lazily calls supabase.com, and this exchange must never +// leave the process. const notImplemented = () => Promise.reject(new Error('not implemented')); +const FIXED_PROJECT = { + id: 'packed-fixture-ref', + ref: 'packed-fixture-ref', + organization_id: 'packed-fixture-org', + organization_slug: 'packed-fixture-org', + name: 'Packed Fixture', + status: 'UNKNOWN', + created_at: '2026-01-01T00:00:00.000Z', + region: 'us-east-1', +}; + const handler = createSupabaseMcpHandler({ platform: { account: { @@ -21,12 +33,23 @@ const handler = createSupabaseMcpHandler({ getOrganization: notImplemented, listProjects: notImplemented, getProject: notImplemented, - createProject: notImplemented, + createProject: async () => FIXED_PROJECT, pauseProject: notImplemented, restoreProject: notImplemented, + getProjectCreationRate: async () => ({ + amount: 10, + currency: 'USD', + recurrence: 'monthly', + }), }, }, features: ['account'], + // The dependencies a hosted modern route injects. + elicitation: { + actorId: 'packed-fixture-approver', + stateKey: 'a-platform-managed-state-key-long-enough', + formDeliveryAvailable: true, + }, }); const transport = new StreamableHTTPClientTransport( @@ -39,9 +62,29 @@ const transport = new StreamableHTTPClientTransport( const client = new Client( { name: 'packed-platform-consumer-fixture', version: '0.0.0' }, - { versionNegotiation: { mode: { pin: MODERN_PROTOCOL_VERSION } } } + { + capabilities: { elicitation: {} }, + versionNegotiation: { mode: { pin: MODERN_PROTOCOL_VERSION } }, + } ); +let elicitations = 0; +client.setRequestHandler('elicitation/create', async (request) => { + elicitations += 1; + const { message, requestedSchema } = request.params; + + if (typeof message !== 'string' || !message.includes('10 USD')) { + throw new Error(`confirmation message lost its rate: ${message}`); + } + if (Object.keys(requestedSchema?.properties ?? {}).length > 0) { + throw new Error( + `confirmation asked for properties: ${JSON.stringify(requestedSchema)}` + ); + } + + return { action: 'accept' }; +}); + await client.connect(transport); const { tools } = await client.listTools(); @@ -78,6 +121,45 @@ if (missingCreateProjectProperties.length > 0) { ); } +// The legacy confirmation surface is gone for a capable client, and the +// confirmation happens inside the creation call instead. +if (tools.some((tool) => tool.name === 'confirm_cost')) { + throw new Error('a form-capable client was offered confirm_cost'); +} +if (createProjectProperties.includes('confirm_cost_id')) { + throw new Error('a form-capable client was offered confirm_cost_id'); +} + +const created = await client.callTool({ + name: 'create_project', + arguments: { + name: 'Packed Fixture', + region: 'us-east-1', + organization_id: 'packed-fixture-org', + }, +}); + +if (elicitations !== 1) { + throw new Error(`expected exactly one confirmation, saw ${elicitations}`); +} +if (created.structuredContent?.ref !== FIXED_PROJECT.ref) { + throw new Error( + `confirmed creation lost its business output: ${JSON.stringify(created)}` + ); +} + +// The runtime, its state, its codec, and the policy stay private: a hosted +// consumer injects dependencies and never reaches inside. +const leaked = Object.keys(packageExports).filter((name) => + /elicitation|runtime|continuation|codec|policy|interaction|replay|cost/i.test( + name + ) +); + +if (leaked.length > 0) { + throw new Error(`package entry point leaked private symbols: ${leaked}`); +} + await client.close(); await handler.close(); diff --git a/scripts/fixtures/packed-platform-consumer/types-check.ts b/scripts/fixtures/packed-platform-consumer/types-check.ts index 5e64e7c3..7b6307a3 100644 --- a/scripts/fixtures/packed-platform-consumer/types-check.ts +++ b/scripts/fixtures/packed-platform-consumer/types-check.ts @@ -1,12 +1,35 @@ import { createSupabaseMcpHandler, + type SupabaseElicitationOptions, type SupabaseMcpServerOptions, } from '@supabase/mcp-server-supabase'; -// A stubbed `account` platform, whose seven operations only ever run on a -// tool call and so can reject here. It buys the thing that matters: asking -// for the `account` feature group makes the server register real tools, so -// the typecheck covers the zod-backed tool surface rather than an empty one. +// The supported hosted-injection surface, pinned exactly. A private runtime +// knob reaching the packed declarations (a clock, a correlation-id seam, a +// configurable lifetime) fails here rather than in a hosted deployment. +type HostedInjectionKeys = + | 'stateKey' + | 'actorId' + | 'formDeliveryAvailable' + | 'optOut' + | 'gate'; + +type Exactly = [Actual] extends [Expected] + ? [Expected] extends [Actual] + ? true + : { unexpected: Exclude } + : { missing: Exclude }; + +const elicitationKeysArePinned: Exactly< + keyof SupabaseElicitationOptions, + HostedInjectionKeys +> = true; +void elicitationKeysArePinned; + +// A stubbed `account` platform, whose operations only ever run on a tool call +// and so can reject here. It buys the thing that matters: asking for the +// `account` feature group makes the server register real tools, so the +// typecheck covers the zod-backed tool surface rather than an empty one. const account: SupabaseMcpServerOptions['platform']['account'] = { listOrganizations: () => Promise.reject(new Error('not implemented')), getOrganization: () => Promise.reject(new Error('not implemented')), @@ -15,11 +38,28 @@ const account: SupabaseMcpServerOptions['platform']['account'] = { createProject: () => Promise.reject(new Error('not implemented')), pauseProject: () => Promise.reject(new Error('not implemented')), restoreProject: () => Promise.reject(new Error('not implemented')), + getProjectCreationRate: () => Promise.reject(new Error('not implemented')), }; const options: SupabaseMcpServerOptions = { platform: { account }, features: ['account'], + // Exactly what a hosted modern route injects. If any of it stops being + // consumable from the packed artifact, this typecheck fails rather than a + // deployment. + elicitation: { + actorId: 'auth-grant-id', + stateKey: 'a-platform-managed-state-key-long-enough', + formDeliveryAvailable: true, + optOut: false, + gate: () => null, + }, + onToolPolicyCall: ({ name, decision, telemetry }) => { + void name; + void decision; + void telemetry.interactionId; + void telemetry.outcome; + }, }; const handler = createSupabaseMcpHandler(options); From 48f28721b93f3e9e3e30c07c9221544e1ff704cb Mon Sep 17 00:00:00 2001 From: Barry Roodt Date: Mon, 24 Aug 2026 23:17:55 +0200 Subject: [PATCH 4/4] test: pin the version 1 state rejection The one case kept from the Boolean confirmation contract, kept as a policy-version rejection: state a previous deployment issued carries `confirm: false`, and version 2 refuses it without reading that content. Version binding belongs to the runtime; this pins the product consequence, which is that nothing is created and the caller is told to run the tool again. --- .../src/elicitations.test.ts | 76 ++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/packages/mcp-server-supabase/src/elicitations.test.ts b/packages/mcp-server-supabase/src/elicitations.test.ts index 6a35a817..3b52549c 100644 --- a/packages/mcp-server-supabase/src/elicitations.test.ts +++ b/packages/mcp-server-supabase/src/elicitations.test.ts @@ -5,6 +5,7 @@ import { import type { ClientCapabilities, ElicitResult, + ServerContext, } from '@modelcontextprotocol/server'; import { StreamTransport } from '@supabase/mcp-utils'; import { describe, expect, test } from 'vitest'; @@ -15,7 +16,14 @@ import { FIXED_PROJECT, PROJECT_RATE, } from '../test/cost-platform.js'; +import { + canonicalArgumentsDigest, + createSignedStateCodec, + createStateSigner, +} from './elicitations/codec.js'; +import type { ContinuationState } from './elicitations/state.js'; import type { CreationRate } from './platform/types.js'; +import { COST_CONFIRMATION_POLICY_ID } from './policies/cost-confirmation.js'; import { createSupabaseMcpServer } from './server.js'; import { createSupabaseMcpHandler } from './transports/http.js'; @@ -31,6 +39,8 @@ type SetupOptions = { optOut?: boolean; readOnly?: boolean; answers?: ElicitResult[]; + /** Continuation state to attach to every tool call this client makes. */ + requestState?: string; }; /** A modern hosted connection whose serving path can deliver a form. */ @@ -50,7 +60,20 @@ async function setupModern(options: SetupOptions = {}) { }); const transport = new StreamableHTTPClientTransport(MCP_ENDPOINT, { - fetch: async (url, init) => handler.fetch(new Request(url, init)), + fetch: async (url, init) => { + if (options.requestState === undefined) { + return handler.fetch(new Request(url, init)); + } + + // Stands in for a client redeeming state a previous deployment issued. + const body = await new Request(url, init).json(); + if (body?.method === 'tools/call') { + body.params.requestState = options.requestState; + } + return handler.fetch( + new Request(url, { ...init, body: JSON.stringify(body) }) + ); + }, }); const capabilities = options.capabilities ?? { elicitation: {} }; const client = new Client( @@ -142,6 +165,34 @@ const PROJECT_ARGS = { const BRANCH_ARGS = { project_id: 'fixed-project-ref', name: 'develop' }; +/** + * State a previous deployment issued: same key, same actor, version 1 of this + * policy, and the Boolean the old contract read consent from. + */ +async function version1State(args: Record) { + const codec = createSignedStateCodec({ + signer: createStateSigner(STATE_KEY), + bind: (ctx) => `${ACTOR_ID}\u0000${ctx.mcpReq.method}`, + clock: Date.now, + }); + const issuedAt = Math.floor(Date.now() / 1_000); + + return codec.mint( + { + v: 1, + policy: COST_CONFIRMATION_POLICY_ID, + policyVersion: 1, + tool: 'create_project', + argsDigest: await canonicalArgumentsDigest(args), + proposal: { confirm: false }, + jti: 'version-1-state', + iat: issuedAt, + exp: issuedAt + 120, + }, + { mcpReq: { method: 'tools/call' } } as unknown as ServerContext + ); +} + describe('modern capable creation', () => { test('an accepted project is created with its business output unchanged', async () => { const { client, elicited } = await setupModern(); @@ -391,6 +442,29 @@ describe('read-only servers', () => { }); }); +describe('rolling deployment', () => { + test('state issued by policy version 1 creates nothing and says so', async () => { + // The one case kept from the Boolean contract, and it is kept as a + // version rejection rather than a Boolean one: the old payload carried + // `confirm: false`, and this policy refuses it without ever looking at + // that content. Version binding itself belongs to the runtime; what is + // proved here is the product consequence. + const { client, calls } = await setupModern({ + requestState: await version1State(PROJECT_ARGS), + }); + + const result = await client.callTool({ + name: 'create_project', + arguments: PROJECT_ARGS, + }); + + expect(result.isError).toBe(true); + expect(textOf(result)).toContain('Run the tool again'); + expect(calls).not.toContain('create_project'); + expect(result.structuredContent).toBeUndefined(); + }); +}); + describe('surfaces that stay on the legacy contract', () => { test('a modern client that declares no elicitation keeps confirm_cost', async () => { const { client } = await setupModern({ capabilities: {} });