diff --git a/.github/workflows/check-management-api-types.yml b/.github/workflows/check-management-api-types.yml index 866274d1..d5e92043 100644 --- a/.github/workflows/check-management-api-types.yml +++ b/.github/workflows/check-management-api-types.yml @@ -20,7 +20,7 @@ jobs: run: pnpm --filter @supabase/mcp-server-supabase generate:management-api-types - name: Check for diff run: | - if ! git diff --exit-code packages/mcp-server-supabase/src/management-api/types.ts; then + if ! git diff --exit-code packages/mcp-server-supabase/src/management-api/types.ts packages/mcp-server-supabase/src/management-api/v2-types.ts; then echo "Management API types are out of date. Run 'pnpm generate:management-api-types' to update." exit 1 fi 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..ebf9ca45 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 10250c8bd4c29332f10cec5556ee6b5ebc8766f39f9f763cb2e3a0d3db71d1bb 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/elicitations.test.ts b/packages/mcp-server-supabase/src/elicitations.test.ts new file mode 100644 index 00000000..3b52549c --- /dev/null +++ b/packages/mcp-server-supabase/src/elicitations.test.ts @@ -0,0 +1,543 @@ +import { + Client, + StreamableHTTPClientTransport, +} from '@modelcontextprotocol/client'; +import type { + ClientCapabilities, + ElicitResult, + ServerContext, +} 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 { + 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'; + +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[]; + /** Continuation state to attach to every tool call this client makes. */ + requestState?: string; +}; + +/** 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) => { + 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( + { 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' }; + +/** + * 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(); + + 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('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: {} }); + + 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/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/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/types.ts b/packages/mcp-server-supabase/src/management-api/types.ts index f675abf1..0f614970 100644 --- a/packages/mcp-server-supabase/src/management-api/types.ts +++ b/packages/mcp-server-supabase/src/management-api/types.ts @@ -1284,7 +1284,7 @@ export interface paths { * If both are not provided, only the last 1 minute of logs will be queried. * The timestamp range must be no more than 24 hours and is rounded to the nearest minute. If the range is more than 24 hours, a validation error will be thrown. * - * Note: Unless the `sql` parameter is provided, only edge_logs will be queried. See the [log query docs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer:~:text=logs%20from%20the-,Sources,-drop%2Ddown%3A) for all available sources. + * Note: Unless the `sql` parameter is provided, only edge_logs will be queried. See the [log query docs](https://supabase.com/docs/guides/monitoring-and-debugging/logs#logs-explorer) for all available sources. * */ get: operations["v1-get-project-logs-all"]; @@ -9798,7 +9798,7 @@ export interface operations { "v1-get-project-logs-all": { parameters: { query?: { - /** @description Custom SQL query to execute on the logs. See [querying logs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer) for more details. */ + /** @description Custom SQL query to execute on the logs. See [querying logs](https://supabase.com/docs/guides/monitoring-and-debugging/logs#querying-with-the-logs-explorer) for more details. */ sql?: string; iso_timestamp_start?: string; iso_timestamp_end?: string; @@ -9853,7 +9853,7 @@ export interface operations { "v1-get-project-logs": { parameters: { query?: { - /** @description Custom SQL query to execute on the logs. See [querying logs](/docs/guides/telemetry/logs?queryGroups=product&product=postgres&queryGroups=source&source=edge_logs#querying-with-the-logs-explorer) for more details. */ + /** @description Custom SQL query to execute on the logs. See [querying logs](https://supabase.com/docs/guides/monitoring-and-debugging/logs#querying-with-the-logs-explorer) for more details. */ sql?: string; iso_timestamp_start?: string; iso_timestamp_end?: string; 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.test.ts b/packages/mcp-server-supabase/src/policies/cost-confirmation.test.ts new file mode 100644 index 00000000..1541100b --- /dev/null +++ b/packages/mcp-server-supabase/src/policies/cost-confirmation.test.ts @@ -0,0 +1,301 @@ +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', + 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, + 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 new file mode 100644 index 00000000..159b9956 --- /dev/null +++ b/packages/mcp-server-supabase/src/policies/cost-confirmation.ts @@ -0,0 +1,492 @@ +import { inputRequired } from '@modelcontextprotocol/server'; +import type { + ToolPolicy, + ToolPolicyDecision, + ToolRequestContext, +} from '@supabase/mcp-utils'; + +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'; + +/** + * 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 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. The key is the + * output's JSON bytes rather than the result object: the server validates a + * round-tripped snapshot and hands the text hook its own copy, so no object + * identity survives from `execute` to `render`. Those bytes are an exact key, + * because the same server refuses to emit output its schema did not accept + * as-is, which makes the rendered copy byte-identical to the recorded result. + * + * 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. A recorded + * ceiling is consumed once: the normalized lane is the only one that records, + * and it always renders. + */ +export function createCreationOutcomeText( + action: CostConfirmationSubject['action'] +): CreationOutcomeText { + const approved = new Map(); + + return { + record(result, resolution) { + if (resolution !== undefined) { + approved.set(JSON.stringify(result), resolution.maximumCreationRate); + } + }, + + render(result, resourceName) { + const key = JSON.stringify(result); + const ceiling = approved.get(key); + approved.delete(key); + // 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']; + /** 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, + 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; + + /** + * 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 runtimeOwns(ctx) + ? contextual.omit({ [LEGACY_CONFIRMATION_FIELD]: true }) + : contextual; + }, + + outputSchema(schema, ctx) { + // 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 ( + !runtimeOwns(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 (runtimeOwns(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 { + 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 { + 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/policies/legacy-cost-bytes.test.ts b/packages/mcp-server-supabase/src/policies/legacy-cost-bytes.test.ts new file mode 100644 index 00000000..fa91f45a --- /dev/null +++ b/packages/mcp-server-supabase/src/policies/legacy-cost-bytes.test.ts @@ -0,0 +1,318 @@ +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', + 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', + 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/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..61afe497 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'; @@ -15,9 +19,59 @@ 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'; +/** + * 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 & { isError: true }) | null; +}; + const { version } = packageJson; export type SupabaseMcpServerOptions = { @@ -50,6 +104,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. */ @@ -66,8 +140,6 @@ const DEFAULT_FEATURES: FeatureGroup[] = [ 'branching', ]; -export const PLATFORM_INDEPENDENT_FEATURES: FeatureGroup[] = ['docs']; - export const instructions = ` Here are guidelines for using Supabase tools effectively: @@ -96,8 +168,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}`, }); @@ -133,6 +223,8 @@ export function createSupabaseMcpServer(options: SupabaseMcpServerOptions) { ), ]); }, + requestState: elicitation?.requestState, + onToolPolicyCall, onToolCall, tools: async () => { const contentApiClient = await contentApiClientPromise; @@ -153,7 +245,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')) { @@ -185,7 +280,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 e35b8ceb..5cf9b81c 100644 --- a/packages/mcp-server-supabase/src/tools/account-tools.ts +++ b/packages/mcp-server-supabase/src/tools/account-tools.ts @@ -1,15 +1,30 @@ -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 { getBranchCost, getNextProjectCost } from '../pricing.js'; +import { + assertRateStillApproved, + createCostConfirmationPolicy, + createCreationOutcomeText, + routeCostConfirmation, + routeLegacyConfirmation, + 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'; 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({}); @@ -215,7 +230,44 @@ export const accountToolDefs = { }, } as const satisfies ToolDefs; -export function getAccountTools({ account, readOnly }: AccountToolsOptions) { +export function getAccountTools({ + account, + 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) => + costConfirmation?.availability(ctx).formElicitation === true; + const projectOutcome = createCreationOutcomeText('create_project'); + + const createProjectPolicy = + costConfirmation && + routeCostConfirmation({ + capable, + confirmed: costConfirmation.policy( + 'create_project', + createCostConfirmationPolicy>({ + action: 'create_project', + // 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, @@ -246,9 +298,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}`); } @@ -256,30 +311,59 @@ 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. + hidden: (ctx) => capable(ctx), + policy: costConfirmation && routeLegacyConfirmation({ capable }), execute: async (cost) => { 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 }) => { + 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 + ) => { 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 ); } - 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 bbad5976..2fc28a89 100644 --- a/packages/mcp-server-supabase/src/tools/branching-tools.ts +++ b/packages/mcp-server-supabase/src/tools/branching-tools.ts @@ -1,8 +1,16 @@ -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 { getBranchCost } from '../pricing.js'; +import { + assertRateStillApproved, + createCostConfirmationPolicy, + createCreationOutcomeText, + routeCostConfirmation, + type CostConfirmationResolution, +} from '../policies/cost-confirmation.js'; +import { legacyBranchCost } from '../pricing.js'; import { hashObject } from '../util.js'; import { injectableTool, type ToolDefs } from './util.js'; @@ -10,6 +18,8 @@ type BranchingToolsOptions = { branching: BranchingOperations; projectId?: string; readOnly?: boolean; + /** Present only when this connection serves form elicitation. */ + elicitation?: ElicitationRuntime; }; const createBranchInputSchema = z.object({ @@ -155,26 +165,74 @@ export function getBranchingTools({ branching, projectId, readOnly, + 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) => + costConfirmation?.availability(ctx).formElicitation === true; + const branchOutcome = createCreationOutcomeText('create_branch'); + + const createBranchPolicy = + costConfirmation && + routeCostConfirmation({ + capable, + confirmed: costConfirmation.policy( + 'create_branch', + createCostConfirmationPolicy>({ + action: 'create_branch', + 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({ + create_branch: injectableTool< + typeof createBranchInputSchema, + typeof createBranchOutputSchema, + { project_id: string | undefined }, + CostConfirmationResolution | undefined + >({ ...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 }) => { + 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 }); + + const branch = await branching.createBranch(project_id, { name }); + + branchOutcome.record(branch, resolution); + + return branch; }, }), list_branches: injectableTool({ diff --git a/packages/mcp-server-supabase/src/tools/util.ts b/packages/mcp-server-supabase/src/tools/util.ts index 14cd7807..7a0c81b7 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,18 @@ export function injectableTool< Params extends z.ZodObject, OutputSchema extends z.ZodObject, Injected extends Partial>, + Resolution = never, >({ description, annotations, parameters, outputSchema, hidden, + 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 +59,9 @@ export function injectableTool< parameters, outputSchema, hidden, + policy, execute, + formatResult, }); } @@ -71,9 +77,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 +89,17 @@ export function injectableTool< parameters: cleanParametersSchema, outputSchema, hidden, + // 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/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/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; 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);