diff --git a/packages/3-extensions/supabase/scripts/generate-contract.ts b/packages/3-extensions/supabase/scripts/generate-contract.ts index 846851c0ceb2..fdaf0e802a46 100644 --- a/packages/3-extensions/supabase/scripts/generate-contract.ts +++ b/packages/3-extensions/supabase/scripts/generate-contract.ts @@ -28,6 +28,7 @@ import { namespacePslExtensionBlocks, type PslDocumentAst, type PslExtensionBlock, + type PslField, type PslModel, type PslNamedTypeDeclaration, type PslNamespace, @@ -281,68 +282,104 @@ function rewriteFieldTypeNames( }); } -function namedTypeSignature(declaration: PslNamedTypeDeclaration): string { - return JSON.stringify({ - baseType: declaration.baseType, - typeConstructor: declaration.typeConstructor, - attributes: declaration.attributes, - }); +/** + * Curated storage-type aliases, keyed by the type as `contract infer` writes + * it. Hand-authored in the pack's first contract (commit 7a9426e2, + * "using named types for the uuid/timestamptz column types") and preserved + * here so `contract:generate` reproduces them instead of inlining every + * column's full type. + * + * The alias is chosen by how the type is written, never by what the column + * means: a new Supabase release that adds any `character varying(255)` column + * will have it named `Parent`, whether or not that reads correctly. Check the + * names after refreshing the fixture. + */ +const NAMED_TYPE_ALIASES: Readonly> = { + Inet: 'IpAddress', + Json: 'Payload', + SmallInt: 'EmailChangeConfirmStatus', + Timestamp: 'CreatedAt', + Uuid: 'Id', + 'VarChar(40)': 'Hash', + 'VarChar(64)': 'IpAddress2', + 'VarChar(100)': 'Name', + 'VarChar(255)': 'Parent', +}; + +/** The type as `printPsl` would write it, e.g. `Uuid` or `VarChar(255)`. */ +function printedFieldType(field: PslField): string { + const { typeConstructor } = field; + if (!typeConstructor) return field.typeName; + const path = typeConstructor.path.join('.'); + if (typeConstructor.args.length === 0) return path; + const args = typeConstructor.args.map((arg) => + arg.kind === 'positional' ? arg.value : `${arg.name}: ${arg.value}`, + ); + return `${path}(${args.join(', ')})`; } /** - * `auth` and `storage` are inferred independently, so each seeds its own - * named-type registry from its own columns — the same underlying `Uuid` - * storage type can come out as `Id` in one schema and `Owner` in the other. Groups every declaration by structural signature (ignoring name), - * keeps one canonical declaration per signature (the first-seen — `auth`'s - * declarations are passed first), and returns the old-name -> canonical-name - * map for every non-canonical name so callers fold it into the global - * field-`typeName` rewrite alongside the model-rename maps. + * Rewrites every scalar field whose printed type has an alias to reference + * that alias, and records which aliases were used so only those are declared. + * `modelNames` keeps a relation field out of the lookup: its type name is the + * target model's name, which could one day collide with an alias name. */ -function canonicalizeNamedTypes( - declarationLists: readonly (readonly PslNamedTypeDeclaration[])[], -): { - readonly declarations: readonly PslNamedTypeDeclaration[]; - readonly renameMap: ReadonlyMap; -} { - const bySignature = new Map(); - for (const list of declarationLists) { - for (const declaration of list) { - const signature = namedTypeSignature(declaration); - const group = bySignature.get(signature); - if (group) { - group.push(declaration); - } else { - bySignature.set(signature, [declaration]); +function applyNamedTypeAliases( + namespace: PslNamespace, + modelNames: ReadonlySet, + used: Set, +): PslNamespace { + let changed = false; + const models = namespace.models.map((model) => { + const fields = model.fields.map((field) => { + if ( + field.typeNamespaceId !== undefined || + field.typeContractSpaceId !== undefined || + modelNames.has(field.typeName) + ) { + return field; } - } - } + const alias = NAMED_TYPE_ALIASES[printedFieldType(field)]; + if (alias === undefined) return field; + changed = true; + used.add(alias); + const { typeConstructor: _replacedByAlias, ...rest } = field; + return { ...rest, typeName: alias }; + }); + return { ...model, fields }; + }); - const declarations: PslNamedTypeDeclaration[] = []; - const renameMap = new Map(); - for (const group of bySignature.values()) { - const [canonical] = group; - if (!canonical) continue; - declarations.push(canonical); - for (const declaration of group) { - if (declaration.name !== canonical.name) { - renameMap.set(declaration.name, canonical.name); - } - } - } - declarations.sort((a, b) => a.name.localeCompare(b.name)); + if (!changed) return namespace; - return { declarations, renameMap }; + return makePslNamespace({ + kind: 'namespace', + name: namespace.name, + entries: makePslNamespaceEntries( + models, + namespace.compositeTypes, + namespacePslExtensionBlocks(namespace), + ), + span: namespace.span, + }); } -interface InferredSchema { - readonly namespace: PslNamespace; - readonly types: readonly PslNamedTypeDeclaration[]; +function namedTypeDeclarations(used: ReadonlySet): readonly PslNamedTypeDeclaration[] { + return Object.entries(NAMED_TYPE_ALIASES) + .filter(([, alias]) => used.has(alias)) + .map(([baseType, name]) => ({ + kind: 'namedType' as const, + name, + baseType, + attributes: [], + span: SYNTHETIC_SPAN, + })) + .sort((a, b) => a.name.localeCompare(b.name)); } async function introspectSchema( driver: Awaited>, schemaName: string, -): Promise { +): Promise { const controlStack = createControlStack({ family: sqlFamilyDescriptor, target: postgresTargetDescriptor, @@ -370,8 +407,7 @@ async function introspectSchema( // `@@rls` is emitted natively by `inferPslContract` from each table node's // `rlsEnabled` — no out-of-band appender needed. - const defaultsFixed = applyDefaultOmissions(namespace, DEFAULT_OMISSIONS[schemaName] ?? {}); - return { namespace: defaultsFixed, types: ast.types?.declarations ?? [] }; + return applyDefaultOmissions(namespace, DEFAULT_OMISSIONS[schemaName] ?? {}); } async function main(): Promise { @@ -393,8 +429,8 @@ async function main(): Promise { } const driver = await postgresDriverDescriptor.create(connectionString); - let auth: InferredSchema; - let storage: InferredSchema; + let auth: PslNamespace; + let storage: PslNamespace; try { auth = await introspectSchema(driver, 'auth'); storage = await introspectSchema(driver, 'storage'); @@ -403,31 +439,35 @@ async function main(): Promise { if (database) await database.close(); } - const authRenamed = renameModels(auth.namespace, MODEL_RENAMES['auth'] ?? {}); - const storageRenamed = renameModels(storage.namespace, MODEL_RENAMES['storage'] ?? {}); - const { declarations: canonicalTypes, renameMap: typeRenameMap } = canonicalizeNamedTypes([ - auth.types, - storage.types, - ]); + const authRenamed = renameModels(auth, MODEL_RENAMES['auth'] ?? {}); + const storageRenamed = renameModels(storage, MODEL_RENAMES['storage'] ?? {}); const globalRenameMap = new Map([ ...authRenamed.renameMap, ...storageRenamed.renameMap, - ...typeRenameMap, ]); + const renamedNamespaces = [authRenamed.namespace, storageRenamed.namespace].map((namespace) => + rewriteFieldTypeNames(namespace, globalRenameMap), + ); + const modelNames = new Set( + renamedNamespaces.flatMap((namespace) => namespace.models.map((model) => model.name)), + ); + const usedAliases = new Set(); const namespaces = [ roleNamespace(), - rewriteFieldTypeNames(authRenamed.namespace, globalRenameMap), - rewriteFieldTypeNames(storageRenamed.namespace, globalRenameMap), + ...renamedNamespaces.map((namespace) => + applyNamedTypeAliases(namespace, modelNames, usedAliases), + ), ]; + const declarations = namedTypeDeclarations(usedAliases); const merged: PslDocumentAst = { kind: 'document', sourceId: 'supabase-reference', namespaces, - ...(canonicalTypes.length > 0 - ? { types: { kind: 'types', declarations: canonicalTypes, span: SYNTHETIC_SPAN } } + ...(declarations.length > 0 + ? { types: { kind: 'types', declarations, span: SYNTHETIC_SPAN } } : {}), span: SYNTHETIC_SPAN, }; diff --git a/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md b/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md index 40f63c7fa6d5..b09a662187ae 100644 --- a/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md +++ b/packages/3-extensions/supabase/src/contract/CONTRACT-FIDELITY.md @@ -1,6 +1,6 @@ # Contract fidelity notes -The shipped contract (`contract.prisma` → emitted `contract.json` / `contract.d.ts`) is **generated, not hand-authored**: `pnpm contract:generate` restores the reference fixture ([`test/fixtures/supabase-reference/`](../../test/fixtures/supabase-reference/)) into a fresh PGlite database, introspects the `auth` and `storage` schemas, infers PSL per schema, assembles the `auth`/`storage` `namespace` blocks plus a `namespace unbound { }` block carrying the three `role` blocks (from `src/contract/roles.ts`'s `SupabaseRole.values`), and emits. Rerunning the generator today does not reproduce the committed file: its output inlines the hand-authored `types {}` alias block, declares 43 `@@check` constraints the committed contract omits, writes `DateTime` columns as `Timestamptz` (same codec), and prints one enum default as a literal instead of `dbgenerated(...)`; reconciling the committed contract with the generator is tracked separately. `contract.prisma` is fully self-describing — nothing is injected outside of PSL text during emit. +The shipped contract (`contract.prisma` → emitted `contract.json` / `contract.d.ts`) is **generated, not hand-authored**: `pnpm contract:generate` restores the reference fixture ([`test/fixtures/supabase-reference/`](../../test/fixtures/supabase-reference/)) into a fresh PGlite database, introspects the `auth` and `storage` schemas, infers PSL per schema, assembles the `types { }` alias block, the `auth`/`storage` `namespace` blocks and a `namespace unbound { }` block carrying the three `role` blocks (from `src/contract/roles.ts`'s `SupabaseRole.values`), and emits. Rerunning it reproduces the committed file exactly — a second run leaves `git status` clean — so the three files here are the generator's own output and nothing else. `contract.prisma` is fully self-describing: nothing is injected outside of PSL text during emit. **Reference version:** supabase/postgres:17.6.1.106 (PostgreSQL 17.6), gotrue v2.188.1, storage-api v1.54.1, captured 2026-07-12 with supabase CLI 2.95.4. Supabase-internal schema drifts across platform upgrades; refresh by re-capturing the fixture from a newer stack and rerunning `contract:generate`. @@ -8,13 +8,15 @@ The shipped contract (`contract.prisma` → emitted `contract.json` / `contract. Everything the pack declares is `control: 'external'`. Under `external`, `db verify` **fails on a declared shape the live database lacks** and **tolerates everything live that the contract does not declare** (extra schemas, tables, columns, indexes, defaults). So *under-declaring is safe and wrong-declaring is not* — every entry below is an omission, never an approximation. The round-trip test (`test/reference-fixture-verify.integration.test.ts`) pins that the shipped contract verifies clean against the restored reference, with the undeclared schemas (`realtime`, `vault`, …) present. +The 43 `CHECK` constraints are a newly declared surface, read from the one pinned reference build below. They were previously a tolerated live extra; now they are a declared shape, so a consumer whose Supabase build declares a different constraint set fails verify and cannot repair it, because no plan may emit DDL against an `external` table. That is the same bet the pack already makes on tables, native enums and indexes, and it is the reason the reference version is pinned and the fixture refresh is a deliberate step. + ## What the contract deliberately does not declare The machine-readable version of the default list lives in `scripts/generate-contract.ts` (`DEFAULT_OMISSIONS`) with the full reasoning; this is the audit summary. **Columns:** none. Every live column of every declared table is declared, including the nullable `text[]` columns `storage.buckets.allowed_mime_types` and `storage.objects.path_tokens` (`String[]?`). `path_tokens` is `GENERATED ALWAYS`, so it is declared but not user-writable. -**Column defaults (1):** `auth.users.phone`. Its live `DEFAULT NULL` on a nullable column is a no-op (the same as no default at all), but the raw-default parser round-trips it as an explicit `@default(null)`, which the interpreter rejects (`PSL_INVALID_DEFAULT_VALUE` — `null` is not a value literal). Dropping the default changes nothing observable: the column type is declared in full, it is still nullable, and it still has no enforced default. Every other live default is declared, including the `'{}'::text[]` list defaults on `auth.custom_oauth_providers` (`@default([])`) and the jsonb `dbgenerated(...)` defaults — `db verify`'s permanent-drift disagreement on those is fixed generically, at the postgres target's `SchemaIR` construction, so it needs no authoring-side omission. +**Column defaults (1):** `auth.users.phone`. Its live `DEFAULT NULL` on a nullable column is a no-op (the same as no default at all), but the raw-default parser round-trips it as an explicit `@default(null)`, which the interpreter rejects (`PSL_INVALID_DEFAULT_VALUE` — `null` is not a value literal). Dropping the default changes nothing observable: the column type is declared in full, it is still nullable, and it still has no enforced default. Every other live default is declared, including the `'{}'::text[]` list defaults on `auth.custom_oauth_providers` (`@default([])`) and the jsonb `dbgenerated(...)` defaults — `db verify`'s permanent-drift disagreement on those is fixed generically, at the postgres target's `SchemaIR` construction, so it needs no authoring-side omission. The six native-enum columns whose live default is a cast (`'STANDARD'::storage.buckettype` and friends) are declared as member literals — `@default("STANDARD")` — because `contract infer` reads the member out of the cast; the emitted default is `{ kind: 'literal' }` rather than `{ kind: 'function' }`, and it verifies clean against the same live default. **Indexes:** @@ -24,6 +26,12 @@ The machine-readable version of the default list lives in `scripts/generate-cont **Generated columns** (`auth.users.confirmed_at`, `auth.identities.email`, `storage.objects.path_tokens`): declared as ordinary columns. Introspection reports them identically on the authored and live sides, so verify is clean; the contract does not record the generation expression. +**Check constraints:** all 43 live `CHECK` constraints on a declared table are declared — see "What is complete". The four `text[]` columns additionally carry `@noCheck(elementNotNull)`, which `contract infer` writes for any list column with no live check at the derived wire name; the committed contract reproduces the generator's output, so it carries the waiver too. The waiver's only effect on the emitted artefact is the `"noCheck": ["elementNotNull"]` key, which feeds the storage hash. It changes nothing about what `db verify` demands: the pack's `defaultControlPolicy: 'external'` already runs `stripDerivedChecksFromNonManagedTables` over every table before emit, so a derived check never reaches the contract whether the waiver is written or not. + +## The named types are chosen by how a type is written + +`contract.prisma` opens with a `types { }` block of nine curated aliases (`Id = Uuid`, `Parent = VarChar(255)`, `Payload = Json`, and so on), and 85 columns reference an alias instead of writing their type out. The aliases are hand-picked names, but the generator applies them mechanically, matching on how a type is written rather than on what a column means: the table lives in `scripts/generate-contract.ts` (`NAMED_TYPE_ALIASES`) and maps a printed type such as `VarChar(255)` to a name such as `Parent`. A new Supabase release that adds any `character varying(255)` column therefore picks up the name `Parent` automatically, whether or not that reads correctly. After refreshing the fixture, read the new columns' alias names and rename or add aliases if one of them no longer makes sense. + ## What is complete -Every `auth` (23) and `storage` (10) table of the reference version, all 10 native enum types, and the three platform roles. Schemas the pack does not own (`realtime`, `vault`, `pgsodium`, `extensions`, `graphql*`, `net`, `supabase_functions`, `_realtime`) are deliberately undeclared: they belong to Supabase subsystems and Postgres extensions this pack does not model, and under `external` control an undeclared live schema is a tolerated extra (the safety asymmetry above), so declaring them would add surface without changing what verifies. +Every `auth` (23) and `storage` (10) table of the reference version, all 10 native enum types, the three platform roles, and all 43 `CHECK` constraints the reference declares on a table this pack owns. (The fixture has 45 `CHECK` constraints; the other two are on `_realtime.tenants` and `realtime.subscription`, in schemas the pack does not declare.) Schemas the pack does not own (`realtime`, `vault`, `pgsodium`, `extensions`, `graphql*`, `net`, `supabase_functions`, `_realtime`) are deliberately undeclared: they belong to Supabase subsystems and Postgres extensions this pack does not model, and under `external` control an undeclared live schema is a tolerated extra (the safety asymmetry above), so declaring them would add surface without changing what verifies. diff --git a/packages/3-extensions/supabase/src/contract/contract.d.ts b/packages/3-extensions/supabase/src/contract/contract.d.ts index cd06be3be270..a9341c536425 100644 --- a/packages/3-extensions/supabase/src/contract/contract.d.ts +++ b/packages/3-extensions/supabase/src/contract/contract.d.ts @@ -34,7 +34,7 @@ import type { } from '@internal/contract/types'; export type StorageHash = - StorageHashBase<'ede079259d126d9153bcb4fc4aa6781d870a255585524e1e95fae9e5af4eef89'>; + StorageHashBase<'43f09411473534105017fa715b8932facbdf79feab1bfc75da681beb87f22cbc'>; export type ExecutionHash = ExecutionHashBase; export type ProfileHash = ProfileHashBase<'3916f444a8a17ad749191acf9e08dad97d1a327b88c2f1d45d12f240296aa8b2'>; @@ -3212,8 +3212,8 @@ type ContractBase = Omit< readonly codecId: 'pg/enum@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; - readonly expression: "'code'::auth.oauth_response_type"; + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/enum@1', 'code'>; }; readonly typeParams: { readonly typeName: 'auth.oauth_response_type' }; }; @@ -3232,8 +3232,8 @@ type ContractBase = Omit< readonly codecId: 'pg/enum@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; - readonly expression: "'pending'::auth.oauth_authorization_status"; + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/enum@1', 'pending'>; }; readonly typeParams: { readonly typeName: 'auth.oauth_authorization_status' }; }; @@ -3350,8 +3350,8 @@ type ContractBase = Omit< readonly codecId: 'pg/enum@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; - readonly expression: "'confidential'::auth.oauth_client_type"; + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/enum@1', 'confidential'>; }; readonly typeParams: { readonly typeName: 'auth.oauth_client_type' }; }; @@ -4768,8 +4768,8 @@ type ContractBase = Omit< readonly codecId: 'pg/enum@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; - readonly expression: "'STANDARD'::storage.buckettype"; + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/enum@1', 'STANDARD'>; }; readonly typeParams: { readonly typeName: 'storage.buckettype' }; }; @@ -4833,8 +4833,8 @@ type ContractBase = Omit< readonly codecId: 'pg/enum@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; - readonly expression: "'ANALYTICS'::storage.buckettype"; + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/enum@1', 'ANALYTICS'>; }; readonly typeParams: { readonly typeName: 'storage.buckettype' }; }; @@ -4878,8 +4878,8 @@ type ContractBase = Omit< readonly codecId: 'pg/enum@1'; readonly nullable: false; readonly default: { - readonly kind: 'function'; - readonly expression: "'VECTOR'::storage.buckettype"; + readonly kind: 'literal'; + readonly value: DefaultLiteralValue<'pg/enum@1', 'VECTOR'>; }; readonly typeParams: { readonly typeName: 'storage.buckettype' }; }; diff --git a/packages/3-extensions/supabase/src/contract/contract.json b/packages/3-extensions/supabase/src/contract/contract.json index 9f6dee100c85..e533d3a4aa66 100644 --- a/packages/3-extensions/supabase/src/contract/contract.json +++ b/packages/3-extensions/supabase/src/contract/contract.json @@ -4878,6 +4878,80 @@ "uniques": [] }, "custom_oauth_providers": { + "checks": [ + { + "expression": "((authorization_url IS NULL) OR (authorization_url ~~ 'https://%'::text))", + "name": "custom_oauth_providers_authorization_url_https" + }, + { + "expression": "((authorization_url IS NULL) OR (char_length(authorization_url) <= 2048))", + "name": "custom_oauth_providers_authorization_url_length" + }, + { + "expression": "((char_length(client_id) >= 1) AND (char_length(client_id) <= 512))", + "name": "custom_oauth_providers_client_id_length" + }, + { + "expression": "((discovery_url IS NULL) OR (char_length(discovery_url) <= 2048))", + "name": "custom_oauth_providers_discovery_url_length" + }, + { + "expression": "(identifier ~ '^[a-z0-9][a-z0-9:-]{0,48}[a-z0-9]$'::text)", + "name": "custom_oauth_providers_identifier_format" + }, + { + "expression": "((issuer IS NULL) OR ((char_length(issuer) >= 1) AND (char_length(issuer) <= 2048)))", + "name": "custom_oauth_providers_issuer_length" + }, + { + "expression": "((jwks_uri IS NULL) OR (jwks_uri ~~ 'https://%'::text))", + "name": "custom_oauth_providers_jwks_uri_https" + }, + { + "expression": "((jwks_uri IS NULL) OR (char_length(jwks_uri) <= 2048))", + "name": "custom_oauth_providers_jwks_uri_length" + }, + { + "expression": "((char_length(name) >= 1) AND (char_length(name) <= 100))", + "name": "custom_oauth_providers_name_length" + }, + { + "expression": "((provider_type <> 'oauth2'::text) OR ((authorization_url IS NOT NULL) AND (token_url IS NOT NULL) AND (userinfo_url IS NOT NULL)))", + "name": "custom_oauth_providers_oauth2_requires_endpoints" + }, + { + "expression": "((provider_type <> 'oidc'::text) OR (discovery_url IS NULL) OR (discovery_url ~~ 'https://%'::text))", + "name": "custom_oauth_providers_oidc_discovery_url_https" + }, + { + "expression": "((provider_type <> 'oidc'::text) OR (issuer IS NULL) OR (issuer ~~ 'https://%'::text))", + "name": "custom_oauth_providers_oidc_issuer_https" + }, + { + "expression": "((provider_type <> 'oidc'::text) OR (issuer IS NOT NULL))", + "name": "custom_oauth_providers_oidc_requires_issuer" + }, + { + "expression": "(provider_type = ANY (ARRAY['oauth2'::text, 'oidc'::text]))", + "name": "custom_oauth_providers_provider_type_check" + }, + { + "expression": "((token_url IS NULL) OR (token_url ~~ 'https://%'::text))", + "name": "custom_oauth_providers_token_url_https" + }, + { + "expression": "((token_url IS NULL) OR (char_length(token_url) <= 2048))", + "name": "custom_oauth_providers_token_url_length" + }, + { + "expression": "((userinfo_url IS NULL) OR (userinfo_url ~~ 'https://%'::text))", + "name": "custom_oauth_providers_userinfo_url_https" + }, + { + "expression": "((userinfo_url IS NULL) OR (char_length(userinfo_url) <= 2048))", + "name": "custom_oauth_providers_userinfo_url_length" + } + ], "columns": { "acceptable_client_ids": { "codecId": "pg/text@1", @@ -4887,6 +4961,9 @@ }, "many": true, "nativeType": "text", + "noCheck": [ + "elementNotNull" + ], "nullable": false }, "attribute_mapping": { @@ -5016,6 +5093,9 @@ }, "many": true, "nativeType": "text", + "noCheck": [ + "elementNotNull" + ], "nullable": false }, "skip_nonce_check": { @@ -5681,6 +5761,40 @@ ] }, "oauth_authorizations": { + "checks": [ + { + "expression": "(char_length(authorization_code) <= 255)", + "name": "oauth_authorizations_authorization_code_length" + }, + { + "expression": "(char_length(code_challenge) <= 128)", + "name": "oauth_authorizations_code_challenge_length" + }, + { + "expression": "(expires_at > created_at)", + "name": "oauth_authorizations_expires_at_future" + }, + { + "expression": "(char_length(nonce) <= 255)", + "name": "oauth_authorizations_nonce_length" + }, + { + "expression": "(char_length(redirect_uri) <= 2048)", + "name": "oauth_authorizations_redirect_uri_length" + }, + { + "expression": "(char_length(resource) <= 2048)", + "name": "oauth_authorizations_resource_length" + }, + { + "expression": "(char_length(scope) <= 4096)", + "name": "oauth_authorizations_scope_length" + }, + { + "expression": "(char_length(state) <= 4096)", + "name": "oauth_authorizations_state_length" + } + ], "columns": { "approved_at": { "codecId": "pg/timestamptz-temporal@1", @@ -5764,8 +5878,8 @@ "response_type": { "codecId": "pg/enum@1", "default": { - "expression": "'code'::auth.oauth_response_type", - "kind": "function" + "kind": "literal", + "value": "code" }, "nativeType": "auth.oauth_response_type", "nullable": false, @@ -5792,8 +5906,8 @@ "status": { "codecId": "pg/enum@1", "default": { - "expression": "'pending'::auth.oauth_authorization_status", - "kind": "function" + "kind": "literal", + "value": "pending" }, "nativeType": "auth.oauth_authorization_status", "nullable": false, @@ -5926,6 +6040,24 @@ "uniques": [] }, "oauth_clients": { + "checks": [ + { + "expression": "(char_length(client_name) <= 1024)", + "name": "oauth_clients_client_name_length" + }, + { + "expression": "(char_length(client_uri) <= 2048)", + "name": "oauth_clients_client_uri_length" + }, + { + "expression": "(char_length(logo_uri) <= 2048)", + "name": "oauth_clients_logo_uri_length" + }, + { + "expression": "(token_endpoint_auth_method = ANY (ARRAY['client_secret_basic'::text, 'client_secret_post'::text, 'none'::text]))", + "name": "oauth_clients_token_endpoint_auth_method_check" + } + ], "columns": { "client_name": { "codecId": "pg/text@1", @@ -5940,8 +6072,8 @@ "client_type": { "codecId": "pg/enum@1", "default": { - "expression": "'confidential'::auth.oauth_client_type", - "kind": "function" + "kind": "literal", + "value": "confidential" }, "nativeType": "auth.oauth_client_type", "nullable": false, @@ -6043,6 +6175,20 @@ "uniques": [] }, "oauth_consents": { + "checks": [ + { + "expression": "((revoked_at IS NULL) OR (revoked_at >= granted_at))", + "name": "oauth_consents_revoked_after_granted" + }, + { + "expression": "(char_length(scopes) <= 2048)", + "name": "oauth_consents_scopes_length" + }, + { + "expression": "(char_length(TRIM(BOTH FROM scopes)) > 0)", + "name": "oauth_consents_scopes_not_empty" + } + ], "columns": { "client_id": { "codecId": "pg/uuid@1", @@ -6164,6 +6310,12 @@ ] }, "one_time_tokens": { + "checks": [ + { + "expression": "(char_length(token_hash) > 0)", + "name": "one_time_tokens_token_hash_check" + } + ], "columns": { "created_at": { "codecId": "pg/timestamp-temporal@1", @@ -6408,6 +6560,20 @@ ] }, "saml_providers": { + "checks": [ + { + "expression": "(char_length(entity_id) > 0)", + "name": "entity_id not empty" + }, + { + "expression": "((metadata_url = NULL::text) OR (char_length(metadata_url) > 0))", + "name": "metadata_url not empty" + }, + { + "expression": "(char_length(metadata_xml) > 0)", + "name": "metadata_xml not empty" + } + ], "columns": { "attribute_mapping": { "codecId": "pg/jsonb@1", @@ -6502,6 +6668,12 @@ ] }, "saml_relay_states": { + "checks": [ + { + "expression": "(char_length(request_id) > 0)", + "name": "request_id not empty" + } + ], "columns": { "created_at": { "codecId": "pg/timestamptz-temporal@1", @@ -6636,6 +6808,12 @@ "uniques": [] }, "sessions": { + "checks": [ + { + "expression": "(char_length(scopes) <= 4096)", + "name": "sessions_scopes_length" + } + ], "columns": { "aal": { "codecId": "pg/enum@1", @@ -6806,6 +6984,12 @@ "uniques": [] }, "sso_domains": { + "checks": [ + { + "expression": "(char_length(domain) > 0)", + "name": "domain not empty" + } + ], "columns": { "created_at": { "codecId": "pg/timestamptz-temporal@1", @@ -6878,6 +7062,12 @@ "uniques": [] }, "sso_providers": { + "checks": [ + { + "expression": "((resource_id = NULL::text) OR (char_length(resource_id) > 0))", + "name": "resource_id not empty" + } + ], "columns": { "created_at": { "codecId": "pg/timestamptz-temporal@1", @@ -6930,6 +7120,12 @@ "uniques": [] }, "users": { + "checks": [ + { + "expression": "((email_change_confirm_status >= 0) AND (email_change_confirm_status <= 2))", + "name": "users_email_change_confirm_status_check" + } + ], "columns": { "aud": { "codecId": "sql/varchar@1", @@ -7235,6 +7431,12 @@ ] }, "webauthn_challenges": { + "checks": [ + { + "expression": "(challenge_type = ANY (ARRAY['signup'::text, 'registration'::text, 'authentication'::text]))", + "name": "webauthn_challenges_challenge_type_check" + } + ], "columns": { "challenge_type": { "codecId": "pg/text@1", @@ -7694,8 +7896,8 @@ "type": { "codecId": "pg/enum@1", "default": { - "expression": "'STANDARD'::storage.buckettype", - "kind": "function" + "kind": "literal", + "value": "STANDARD" }, "nativeType": "storage.buckettype", "nullable": false, @@ -7780,8 +7982,8 @@ "type": { "codecId": "pg/enum@1", "default": { - "expression": "'ANALYTICS'::storage.buckettype", - "kind": "function" + "kind": "literal", + "value": "ANALYTICS" }, "nativeType": "storage.buckettype", "nullable": false, @@ -7843,8 +8045,8 @@ "type": { "codecId": "pg/enum@1", "default": { - "expression": "'VECTOR'::storage.buckettype", - "kind": "function" + "kind": "literal", + "value": "VECTOR" }, "nativeType": "storage.buckettype", "nullable": false, @@ -8622,7 +8824,7 @@ "kind": "postgres-schema" } }, - "storageHash": "ede079259d126d9153bcb4fc4aa6781d870a255585524e1e95fae9e5af4eef89", + "storageHash": "43f09411473534105017fa715b8932facbdf79feab1bfc75da681beb87f22cbc", "types": { "CreatedAt": { "codecId": "pg/timestamp-temporal@1", diff --git a/packages/3-extensions/supabase/src/contract/contract.prisma b/packages/3-extensions/supabase/src/contract/contract.prisma index ebc5c2bdc3cd..1aa42b67c552 100644 --- a/packages/3-extensions/supabase/src/contract/contract.prisma +++ b/packages/3-extensions/supabase/src/contract/contract.prisma @@ -15,11 +15,11 @@ types { namespace auth { model AuditLogEntries { - id Id @id(map: "audit_log_entries_pkey") - instanceId Id? @map("instance_id") + id Id @id(map: "audit_log_entries_pkey") + instanceId Id? @map("instance_id") payload Payload? - createdAt DateTime? @map("created_at") - ipAddress IpAddress2 @default("") @map("ip_address") + createdAt Timestamptz? @map("created_at") + ipAddress IpAddress2 @default("") @map("ip_address") @@index([instanceId], map: "audit_logs_instance_id_idx") @@rls @@ -33,34 +33,34 @@ namespace auth { role Parent? email Parent? encryptedPassword Parent? @map("encrypted_password") - emailConfirmedAt DateTime? @map("email_confirmed_at") - invitedAt DateTime? @map("invited_at") + emailConfirmedAt Timestamptz? @map("email_confirmed_at") + invitedAt Timestamptz? @map("invited_at") confirmationToken Parent? @map("confirmation_token") - confirmationSentAt DateTime? @map("confirmation_sent_at") + confirmationSentAt Timestamptz? @map("confirmation_sent_at") recoveryToken Parent? @map("recovery_token") - recoverySentAt DateTime? @map("recovery_sent_at") + recoverySentAt Timestamptz? @map("recovery_sent_at") emailChangeTokenNew Parent? @map("email_change_token_new") emailChange Parent? @map("email_change") - emailChangeSentAt DateTime? @map("email_change_sent_at") - lastSignInAt DateTime? @map("last_sign_in_at") + emailChangeSentAt Timestamptz? @map("email_change_sent_at") + lastSignInAt Timestamptz? @map("last_sign_in_at") rawAppMetaData Jsonb? @map("raw_app_meta_data") rawUserMetaData Jsonb? @map("raw_user_meta_data") isSuperAdmin Boolean? @map("is_super_admin") - createdAt DateTime? @map("created_at") - updatedAt DateTime? @map("updated_at") + createdAt Timestamptz? @map("created_at") + updatedAt Timestamptz? @map("updated_at") phone String? @unique(map: "users_phone_key") - phoneConfirmedAt DateTime? @map("phone_confirmed_at") + phoneConfirmedAt Timestamptz? @map("phone_confirmed_at") phoneChange String? @default("") @map("phone_change") phoneChangeToken Parent? @default("") @map("phone_change_token") - phoneChangeSentAt DateTime? @map("phone_change_sent_at") - confirmedAt DateTime? @map("confirmed_at") + phoneChangeSentAt Timestamptz? @map("phone_change_sent_at") + confirmedAt Timestamptz? @map("confirmed_at") emailChangeTokenCurrent Parent? @default("") @map("email_change_token_current") emailChangeConfirmStatus EmailChangeConfirmStatus? @default(0) @map("email_change_confirm_status") - bannedUntil DateTime? @map("banned_until") + bannedUntil Timestamptz? @map("banned_until") reauthenticationToken Parent? @default("") @map("reauthentication_token") - reauthenticationSentAt DateTime? @map("reauthentication_sent_at") + reauthenticationSentAt Timestamptz? @map("reauthentication_sent_at") isSsoUser Boolean @default(false) @map("is_sso_user") - deletedAt DateTime? @map("deleted_at") + deletedAt Timestamptz? @map("deleted_at") isAnonymous Boolean @default(false) @map("is_anonymous") identities AuthIdentity[] mfaFactors MfaFactors[] @@ -80,21 +80,22 @@ namespace auth { @@index(expression: "instance_id, lower(email::text)", map: "users_instance_id_email_idx") @@index([instanceId], map: "users_instance_id_idx") @@index([isAnonymous], map: "users_is_anonymous_idx") + @@check(expression: "((email_change_confirm_status >= 0) AND (email_change_confirm_status <= 2))", map: "users_email_change_confirm_status_check") @@rls @@map("users") } model AuthIdentity { - id Id @id(map: "identities_pkey") @default(dbgenerated("gen_random_uuid()")) - providerId String @map("provider_id") - userId Id @map("user_id") - identityData Jsonb @map("identity_data") + id Id @id(map: "identities_pkey") @default(dbgenerated("gen_random_uuid()")) + providerId String @map("provider_id") + userId Id @map("user_id") + identityData Jsonb @map("identity_data") provider String - lastSignInAt DateTime? @map("last_sign_in_at") - createdAt DateTime? @map("created_at") - updatedAt DateTime? @map("updated_at") + lastSignInAt Timestamptz? @map("last_sign_in_at") + createdAt Timestamptz? @map("created_at") + updatedAt Timestamptz? @map("updated_at") email String? - user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade, map: "identities_user_id_fkey") + user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade, map: "identities_user_id_fkey") @@unique([providerId, provider], map: "identities_provider_id_provider_unique") @@index([email], map: "identities_email_idx") @@ -112,27 +113,31 @@ namespace auth { clientName String? @map("client_name") clientUri String? @map("client_uri") logoUri String? @map("logo_uri") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @map("updated_at") - deletedAt DateTime? @map("deleted_at") - clientType pg.enum(OauthClientType) @default(dbgenerated("'confidential'::auth.oauth_client_type")) @map("client_type") + createdAt Timestamptz @default(now()) @map("created_at") + updatedAt Timestamptz @default(now()) @map("updated_at") + deletedAt Timestamptz? @map("deleted_at") + clientType pg.enum(OauthClientType) @default("confidential") @map("client_type") tokenEndpointAuthMethod String @map("token_endpoint_auth_method") oauthAuthorizations OauthAuthorizations[] oauthConsents OauthConsents[] sessions AuthSession[] @@index([deletedAt], map: "oauth_clients_deleted_at_idx") + @@check(expression: "(char_length(client_name) <= 1024)", map: "oauth_clients_client_name_length") + @@check(expression: "(char_length(client_uri) <= 2048)", map: "oauth_clients_client_uri_length") + @@check(expression: "(char_length(logo_uri) <= 2048)", map: "oauth_clients_logo_uri_length") + @@check(expression: "(token_endpoint_auth_method = ANY (ARRAY['client_secret_basic'::text, 'client_secret_post'::text, 'none'::text]))", map: "oauth_clients_token_endpoint_auth_method_check") @@map("oauth_clients") } model AuthSession { id Id @id(map: "sessions_pkey") userId Id @map("user_id") - createdAt DateTime? @map("created_at") - updatedAt DateTime? @map("updated_at") + createdAt Timestamptz? @map("created_at") + updatedAt Timestamptz? @map("updated_at") factorId Id? @map("factor_id") aal pg.enum(AalLevel)? - notAfter DateTime? @map("not_after") + notAfter Timestamptz? @map("not_after") refreshedAt CreatedAt? @map("refreshed_at") userAgent String? @map("user_agent") ip IpAddress? @@ -150,40 +155,59 @@ namespace auth { @@index([oauthClientId], map: "sessions_oauth_client_id_idx") @@index([userId], map: "sessions_user_id_idx") @@index([userId, createdAt], map: "user_id_created_at_idx") + @@check(expression: "(char_length(scopes) <= 4096)", map: "sessions_scopes_length") @@rls @@map("sessions") } model CustomOauthProviders { - id Id @id(map: "custom_oauth_providers_pkey") @default(dbgenerated("gen_random_uuid()")) - providerType String @map("provider_type") - identifier String @unique(map: "custom_oauth_providers_identifier_key") + id Id @id(map: "custom_oauth_providers_pkey") @default(dbgenerated("gen_random_uuid()")) + providerType String @map("provider_type") + identifier String @unique(map: "custom_oauth_providers_identifier_key") name String - clientId String @map("client_id") - clientSecret String @map("client_secret") - acceptableClientIds String[] @default([]) @map("acceptable_client_ids") - scopes String[] @default([]) - pkceEnabled Boolean @default(true) @map("pkce_enabled") - attributeMapping Jsonb @default(dbgenerated("'{}'::jsonb")) @map("attribute_mapping") - authorizationParams Jsonb @default(dbgenerated("'{}'::jsonb")) @map("authorization_params") - enabled Boolean @default(true) - emailOptional Boolean @default(false) @map("email_optional") + clientId String @map("client_id") + clientSecret String @map("client_secret") + acceptableClientIds String[] @default([]) @noCheck(elementNotNull) @map("acceptable_client_ids") + scopes String[] @default([]) @noCheck(elementNotNull) + pkceEnabled Boolean @default(true) @map("pkce_enabled") + attributeMapping Jsonb @default(dbgenerated("'{}'::jsonb")) @map("attribute_mapping") + authorizationParams Jsonb @default(dbgenerated("'{}'::jsonb")) @map("authorization_params") + enabled Boolean @default(true) + emailOptional Boolean @default(false) @map("email_optional") issuer String? - discoveryUrl String? @map("discovery_url") - skipNonceCheck Boolean @default(false) @map("skip_nonce_check") - cachedDiscovery Jsonb? @map("cached_discovery") - discoveryCachedAt DateTime? @map("discovery_cached_at") - authorizationUrl String? @map("authorization_url") - tokenUrl String? @map("token_url") - userinfoUrl String? @map("userinfo_url") - jwksUri String? @map("jwks_uri") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @map("updated_at") + discoveryUrl String? @map("discovery_url") + skipNonceCheck Boolean @default(false) @map("skip_nonce_check") + cachedDiscovery Jsonb? @map("cached_discovery") + discoveryCachedAt Timestamptz? @map("discovery_cached_at") + authorizationUrl String? @map("authorization_url") + tokenUrl String? @map("token_url") + userinfoUrl String? @map("userinfo_url") + jwksUri String? @map("jwks_uri") + createdAt Timestamptz @default(now()) @map("created_at") + updatedAt Timestamptz @default(now()) @map("updated_at") @@index([createdAt], map: "custom_oauth_providers_created_at_idx") @@index([enabled], map: "custom_oauth_providers_enabled_idx") @@index([identifier], map: "custom_oauth_providers_identifier_idx") @@index([providerType], map: "custom_oauth_providers_provider_type_idx") + @@check(expression: "((authorization_url IS NULL) OR (authorization_url ~~ 'https://%'::text))", map: "custom_oauth_providers_authorization_url_https") + @@check(expression: "((authorization_url IS NULL) OR (char_length(authorization_url) <= 2048))", map: "custom_oauth_providers_authorization_url_length") + @@check(expression: "((char_length(client_id) >= 1) AND (char_length(client_id) <= 512))", map: "custom_oauth_providers_client_id_length") + @@check(expression: "((discovery_url IS NULL) OR (char_length(discovery_url) <= 2048))", map: "custom_oauth_providers_discovery_url_length") + @@check(expression: "(identifier ~ '^[a-z0-9][a-z0-9:-]{0,48}[a-z0-9]$'::text)", map: "custom_oauth_providers_identifier_format") + @@check(expression: "((issuer IS NULL) OR ((char_length(issuer) >= 1) AND (char_length(issuer) <= 2048)))", map: "custom_oauth_providers_issuer_length") + @@check(expression: "((jwks_uri IS NULL) OR (jwks_uri ~~ 'https://%'::text))", map: "custom_oauth_providers_jwks_uri_https") + @@check(expression: "((jwks_uri IS NULL) OR (char_length(jwks_uri) <= 2048))", map: "custom_oauth_providers_jwks_uri_length") + @@check(expression: "((char_length(name) >= 1) AND (char_length(name) <= 100))", map: "custom_oauth_providers_name_length") + @@check(expression: "((provider_type <> 'oauth2'::text) OR ((authorization_url IS NOT NULL) AND (token_url IS NOT NULL) AND (userinfo_url IS NOT NULL)))", map: "custom_oauth_providers_oauth2_requires_endpoints") + @@check(expression: "((provider_type <> 'oidc'::text) OR (discovery_url IS NULL) OR (discovery_url ~~ 'https://%'::text))", map: "custom_oauth_providers_oidc_discovery_url_https") + @@check(expression: "((provider_type <> 'oidc'::text) OR (issuer IS NULL) OR (issuer ~~ 'https://%'::text))", map: "custom_oauth_providers_oidc_issuer_https") + @@check(expression: "((provider_type <> 'oidc'::text) OR (issuer IS NOT NULL))", map: "custom_oauth_providers_oidc_requires_issuer") + @@check(expression: "(provider_type = ANY (ARRAY['oauth2'::text, 'oidc'::text]))", map: "custom_oauth_providers_provider_type_check") + @@check(expression: "((token_url IS NULL) OR (token_url ~~ 'https://%'::text))", map: "custom_oauth_providers_token_url_https") + @@check(expression: "((token_url IS NULL) OR (char_length(token_url) <= 2048))", map: "custom_oauth_providers_token_url_length") + @@check(expression: "((userinfo_url IS NULL) OR (userinfo_url ~~ 'https://%'::text))", map: "custom_oauth_providers_userinfo_url_https") + @@check(expression: "((userinfo_url IS NULL) OR (char_length(userinfo_url) <= 2048))", map: "custom_oauth_providers_userinfo_url_length") @@map("custom_oauth_providers") } @@ -196,10 +220,10 @@ namespace auth { providerType String @map("provider_type") providerAccessToken String? @map("provider_access_token") providerRefreshToken String? @map("provider_refresh_token") - createdAt DateTime? @map("created_at") - updatedAt DateTime? @map("updated_at") + createdAt Timestamptz? @map("created_at") + updatedAt Timestamptz? @map("updated_at") authenticationMethod String @map("authentication_method") - authCodeIssuedAt DateTime? @map("auth_code_issued_at") + authCodeIssuedAt Timestamptz? @map("auth_code_issued_at") inviteToken String? @map("invite_token") referrer String? oauthClientStateId Id? @map("oauth_client_state_id") @@ -215,11 +239,11 @@ namespace auth { } model Instances { - id Id @id(map: "instances_pkey") + id Id @id(map: "instances_pkey") uuid Id? - rawBaseConfig String? @map("raw_base_config") - createdAt DateTime? @map("created_at") - updatedAt DateTime? @map("updated_at") + rawBaseConfig String? @map("raw_base_config") + createdAt Timestamptz? @map("created_at") + updatedAt Timestamptz? @map("updated_at") @@rls @@map("instances") @@ -228,8 +252,8 @@ namespace auth { model MfaAmrClaims { id Id @id(map: "amr_id_pk") sessionId Id @map("session_id") - createdAt DateTime @map("created_at") - updatedAt DateTime @map("updated_at") + createdAt Timestamptz @map("created_at") + updatedAt Timestamptz @map("updated_at") authenticationMethod String @map("authentication_method") session AuthSession @relation(fields: [sessionId], references: [id], onDelete: Cascade, map: "mfa_amr_claims_session_id_fkey", index: false) @@ -244,11 +268,11 @@ namespace auth { friendlyName String? @map("friendly_name") factorType pg.enum(FactorType) @map("factor_type") status pg.enum(FactorStatus) - createdAt DateTime @map("created_at") - updatedAt DateTime @map("updated_at") + createdAt Timestamptz @map("created_at") + updatedAt Timestamptz @map("updated_at") secret String? phone String? - lastChallengedAt DateTime? @unique(map: "mfa_factors_last_challenged_at_key") @map("last_challenged_at") + lastChallengedAt Timestamptz? @unique(map: "mfa_factors_last_challenged_at_key") @map("last_challenged_at") webAuthnCredential Jsonb? @map("web_authn_credential") webAuthnAaguid Id? @map("web_authn_aaguid") lastWebauthnChallengeData Jsonb? @map("last_webauthn_challenge_data") @@ -264,14 +288,14 @@ namespace auth { } model MfaChallenges { - id Id @id(map: "mfa_challenges_pkey") - factorId Id @map("factor_id") - createdAt DateTime @map("created_at") - verifiedAt DateTime? @map("verified_at") - ipAddress IpAddress @map("ip_address") - otpCode String? @map("otp_code") - webAuthnSessionData Jsonb? @map("web_authn_session_data") - factor MfaFactors @relation(fields: [factorId], references: [id], onDelete: Cascade, map: "mfa_challenges_auth_factor_id_fkey", index: false) + id Id @id(map: "mfa_challenges_pkey") + factorId Id @map("factor_id") + createdAt Timestamptz @map("created_at") + verifiedAt Timestamptz? @map("verified_at") + ipAddress IpAddress @map("ip_address") + otpCode String? @map("otp_code") + webAuthnSessionData Jsonb? @map("web_authn_session_data") + factor MfaFactors @relation(fields: [factorId], references: [id], onDelete: Cascade, map: "mfa_challenges_auth_factor_id_fkey", index: false) @@index([createdAt], map: "mfa_challenge_created_at_idx") @@rls @@ -289,25 +313,33 @@ namespace auth { resource String? codeChallenge String? @map("code_challenge") codeChallengeMethod pg.enum(CodeChallengeMethod)? @map("code_challenge_method") - responseType pg.enum(OauthResponseType) @default(dbgenerated("'code'::auth.oauth_response_type")) @map("response_type") - status pg.enum(OauthAuthorizationStatus) @default(dbgenerated("'pending'::auth.oauth_authorization_status")) + responseType pg.enum(OauthResponseType) @default("code") @map("response_type") + status pg.enum(OauthAuthorizationStatus) @default("pending") authorizationCode String? @unique(map: "oauth_authorizations_authorization_code_key") @map("authorization_code") - createdAt DateTime @default(now()) @map("created_at") - expiresAt DateTime @default(dbgenerated("(now() + '00:03:00'::interval)")) @map("expires_at") - approvedAt DateTime? @map("approved_at") + createdAt Timestamptz @default(now()) @map("created_at") + expiresAt Timestamptz @default(dbgenerated("(now() + '00:03:00'::interval)")) @map("expires_at") + approvedAt Timestamptz? @map("approved_at") nonce String? client OauthClients @relation(fields: [clientId], references: [id], onDelete: Cascade, map: "oauth_authorizations_client_id_fkey", index: false) user AuthUser? @relation(fields: [userId], references: [id], onDelete: Cascade, map: "oauth_authorizations_user_id_fkey", index: false) @@index([expiresAt], map: "oauth_auth_pending_exp_idx", where: "(status = 'pending'::auth.oauth_authorization_status)") + @@check(expression: "(char_length(authorization_code) <= 255)", map: "oauth_authorizations_authorization_code_length") + @@check(expression: "(char_length(code_challenge) <= 128)", map: "oauth_authorizations_code_challenge_length") + @@check(expression: "(expires_at > created_at)", map: "oauth_authorizations_expires_at_future") + @@check(expression: "(char_length(nonce) <= 255)", map: "oauth_authorizations_nonce_length") + @@check(expression: "(char_length(redirect_uri) <= 2048)", map: "oauth_authorizations_redirect_uri_length") + @@check(expression: "(char_length(resource) <= 2048)", map: "oauth_authorizations_resource_length") + @@check(expression: "(char_length(scope) <= 4096)", map: "oauth_authorizations_scope_length") + @@check(expression: "(char_length(state) <= 4096)", map: "oauth_authorizations_state_length") @@map("oauth_authorizations") } model OauthClientStates { - id Id @id(map: "oauth_client_states_pkey") - providerType String @map("provider_type") - codeVerifier String? @map("code_verifier") - createdAt DateTime @map("created_at") + id Id @id(map: "oauth_client_states_pkey") + providerType String @map("provider_type") + codeVerifier String? @map("code_verifier") + createdAt Timestamptz @map("created_at") @@index([createdAt], map: "idx_oauth_client_states_created_at") @@map("oauth_client_states") @@ -318,8 +350,8 @@ namespace auth { userId Id @map("user_id") clientId Id @map("client_id") scopes String - grantedAt DateTime @default(now()) @map("granted_at") - revokedAt DateTime? @map("revoked_at") + grantedAt Timestamptz @default(now()) @map("granted_at") + revokedAt Timestamptz? @map("revoked_at") client OauthClients @relation(fields: [clientId], references: [id], onDelete: Cascade, map: "oauth_consents_client_id_fkey", index: false) user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade, map: "oauth_consents_user_id_fkey", index: false) @@ -327,6 +359,9 @@ namespace auth { @@index([clientId], map: "oauth_consents_active_client_idx", where: "(revoked_at IS NULL)") @@index([userId, clientId], map: "oauth_consents_active_user_client_idx", where: "(revoked_at IS NULL)") @@index([userId, grantedAt], map: "oauth_consents_user_order_idx") + @@check(expression: "((revoked_at IS NULL) OR (revoked_at >= granted_at))", map: "oauth_consents_revoked_after_granted") + @@check(expression: "(char_length(scopes) <= 2048)", map: "oauth_consents_scopes_length") + @@check(expression: "(char_length(TRIM(BOTH FROM scopes)) > 0)", map: "oauth_consents_scopes_not_empty") @@map("oauth_consents") } @@ -343,6 +378,7 @@ namespace auth { @@index([relatesTo], map: "one_time_tokens_relates_to_hash_idx", type: "hash") @@index([tokenHash], map: "one_time_tokens_token_hash_hash_idx", type: "hash") @@index([userId, tokenType], map: "one_time_tokens_user_id_token_type_key", unique: true) + @@check(expression: "(char_length(token_hash) > 0)", map: "one_time_tokens_token_hash_check") @@rls @@map("one_time_tokens") } @@ -353,8 +389,8 @@ namespace auth { token Parent? @unique(map: "refresh_tokens_token_unique") userId Parent? @map("user_id") revoked Boolean? - createdAt DateTime? @map("created_at") - updatedAt DateTime? @map("updated_at") + createdAt Timestamptz? @map("created_at") + updatedAt Timestamptz? @map("updated_at") parent Parent? sessionId Id? @map("session_id") session AuthSession? @relation(fields: [sessionId], references: [id], onDelete: Cascade, map: "refresh_tokens_session_id_fkey", index: false) @@ -371,8 +407,8 @@ namespace auth { model SsoProviders { id Id @id(map: "sso_providers_pkey") resourceId String? @map("resource_id") - createdAt DateTime? @map("created_at") - updatedAt DateTime? @map("updated_at") + createdAt Timestamptz? @map("created_at") + updatedAt Timestamptz? @map("updated_at") disabled Boolean? samlProviders SamlProviders[] samlRelayStates SamlRelayStates[] @@ -380,6 +416,7 @@ namespace auth { @@index(expression: "lower(resource_id)", map: "sso_providers_resource_id_idx", unique: true) @@index([resourceId], map: "sso_providers_resource_id_pattern_idx") + @@check(expression: "((resource_id = NULL::text) OR (char_length(resource_id) > 0))", map: "resource_id not empty") @@rls @@map("sso_providers") } @@ -391,12 +428,15 @@ namespace auth { metadataXml String @map("metadata_xml") metadataUrl String? @map("metadata_url") attributeMapping Jsonb? @map("attribute_mapping") - createdAt DateTime? @map("created_at") - updatedAt DateTime? @map("updated_at") + createdAt Timestamptz? @map("created_at") + updatedAt Timestamptz? @map("updated_at") nameIdFormat String? @map("name_id_format") ssoProvider SsoProviders @relation(fields: [ssoProviderId], references: [id], onDelete: Cascade, map: "saml_providers_sso_provider_id_fkey") @@index([ssoProviderId], map: "saml_providers_sso_provider_id_idx") + @@check(expression: "(char_length(entity_id) > 0)", map: "entity_id not empty") + @@check(expression: "((metadata_url = NULL::text) OR (char_length(metadata_url) > 0))", map: "metadata_url not empty") + @@check(expression: "(char_length(metadata_xml) > 0)", map: "metadata_xml not empty") @@rls @@map("saml_providers") } @@ -407,8 +447,8 @@ namespace auth { requestId String @map("request_id") forEmail String? @map("for_email") redirectTo String? @map("redirect_to") - createdAt DateTime? @map("created_at") - updatedAt DateTime? @map("updated_at") + createdAt Timestamptz? @map("created_at") + updatedAt Timestamptz? @map("updated_at") flowStateId Id? @map("flow_state_id") flowState FlowState? @relation(fields: [flowStateId], references: [id], onDelete: Cascade, map: "saml_relay_states_flow_state_id_fkey", index: false) ssoProvider SsoProviders @relation(fields: [ssoProviderId], references: [id], onDelete: Cascade, map: "saml_relay_states_sso_provider_id_fkey") @@ -416,6 +456,7 @@ namespace auth { @@index([createdAt], map: "saml_relay_states_created_at_idx") @@index([forEmail], map: "saml_relay_states_for_email_idx") @@index([ssoProviderId], map: "saml_relay_states_sso_provider_id_idx") + @@check(expression: "(char_length(request_id) > 0)", map: "request_id not empty") @@rls @@map("saml_relay_states") } @@ -431,46 +472,48 @@ namespace auth { id Id @id(map: "sso_domains_pkey") ssoProviderId Id @map("sso_provider_id") domain String - createdAt DateTime? @map("created_at") - updatedAt DateTime? @map("updated_at") + createdAt Timestamptz? @map("created_at") + updatedAt Timestamptz? @map("updated_at") ssoProvider SsoProviders @relation(fields: [ssoProviderId], references: [id], onDelete: Cascade, map: "sso_domains_sso_provider_id_fkey") @@index(expression: "lower(domain)", map: "sso_domains_domain_idx", unique: true) @@index([ssoProviderId], map: "sso_domains_sso_provider_id_idx") + @@check(expression: "(char_length(domain) > 0)", map: "domain not empty") @@rls @@map("sso_domains") } model WebauthnChallenges { - id Id @id(map: "webauthn_challenges_pkey") @default(dbgenerated("gen_random_uuid()")) - userId Id? @map("user_id") - challengeType String @map("challenge_type") - sessionData Jsonb @map("session_data") - createdAt DateTime @default(now()) @map("created_at") - expiresAt DateTime @map("expires_at") - user AuthUser? @relation(fields: [userId], references: [id], onDelete: Cascade, map: "webauthn_challenges_user_id_fkey") + id Id @id(map: "webauthn_challenges_pkey") @default(dbgenerated("gen_random_uuid()")) + userId Id? @map("user_id") + challengeType String @map("challenge_type") + sessionData Jsonb @map("session_data") + createdAt Timestamptz @default(now()) @map("created_at") + expiresAt Timestamptz @map("expires_at") + user AuthUser? @relation(fields: [userId], references: [id], onDelete: Cascade, map: "webauthn_challenges_user_id_fkey") @@index([expiresAt], map: "webauthn_challenges_expires_at_idx") @@index([userId], map: "webauthn_challenges_user_id_idx") + @@check(expression: "(challenge_type = ANY (ARRAY['signup'::text, 'registration'::text, 'authentication'::text]))", map: "webauthn_challenges_challenge_type_check") @@map("webauthn_challenges") } model WebauthnCredentials { - id Id @id(map: "webauthn_credentials_pkey") @default(dbgenerated("gen_random_uuid()")) - userId Id @map("user_id") - credentialId Bytes @map("credential_id") - publicKey Bytes @map("public_key") - attestationType String @default("") @map("attestation_type") + id Id @id(map: "webauthn_credentials_pkey") @default(dbgenerated("gen_random_uuid()")) + userId Id @map("user_id") + credentialId Bytes @map("credential_id") + publicKey Bytes @map("public_key") + attestationType String @default("") @map("attestation_type") aaguid Id? - signCount BigInt @default(0) @map("sign_count") - transports Jsonb @default(dbgenerated("'[]'::jsonb")) - backupEligible Boolean @default(false) @map("backup_eligible") - backedUp Boolean @default(false) @map("backed_up") - friendlyName String @default("") @map("friendly_name") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @map("updated_at") - lastUsedAt DateTime? @map("last_used_at") - user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade, map: "webauthn_credentials_user_id_fkey") + signCount BigInt @default(0) @map("sign_count") + transports Jsonb @default(dbgenerated("'[]'::jsonb")) + backupEligible Boolean @default(false) @map("backup_eligible") + backedUp Boolean @default(false) @map("backed_up") + friendlyName String @default("") @map("friendly_name") + createdAt Timestamptz @default(now()) @map("created_at") + updatedAt Timestamptz @default(now()) @map("updated_at") + lastUsedAt Timestamptz? @map("last_used_at") + user AuthUser @relation(fields: [userId], references: [id], onDelete: Cascade, map: "webauthn_credentials_user_id_fkey") @@index([credentialId], map: "webauthn_credentials_credential_id_key", unique: true) @@index([userId], map: "webauthn_credentials_user_id_idx") @@ -543,11 +586,11 @@ namespace storage { model BucketsAnalytics { id Id @id(map: "buckets_analytics_pkey") @default(dbgenerated("gen_random_uuid()")) name String - _type pg.enum(Buckettype) @default(dbgenerated("'ANALYTICS'::storage.buckettype")) @map("type") + _type pg.enum(Buckettype) @default("ANALYTICS") @map("type") format String @default("ICEBERG") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @map("updated_at") - deletedAt DateTime? @map("deleted_at") + createdAt Timestamptz @default(now()) @map("created_at") + updatedAt Timestamptz @default(now()) @map("updated_at") + deletedAt Timestamptz? @map("deleted_at") icebergNamespaces IcebergNamespaces[] icebergTables IcebergTables[] @@ -558,9 +601,9 @@ namespace storage { model BucketsVectors { id String @id(map: "buckets_vectors_pkey") - _type pg.enum(Buckettype) @default(dbgenerated("'VECTOR'::storage.buckettype")) @map("type") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @map("updated_at") + _type pg.enum(Buckettype) @default("VECTOR") @map("type") + createdAt Timestamptz @default(now()) @map("created_at") + updatedAt Timestamptz @default(now()) @map("updated_at") vectorIndexes VectorIndexes[] @@rls @@ -571,8 +614,8 @@ namespace storage { id Id @id(map: "iceberg_namespaces_pkey") @default(dbgenerated("gen_random_uuid()")) bucketName String @map("bucket_name") name String - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @map("updated_at") + createdAt Timestamptz @default(now()) @map("created_at") + updatedAt Timestamptz @default(now()) @map("updated_at") metadata Jsonb @default(dbgenerated("'{}'::jsonb")) catalogId Id @map("catalog_id") icebergTables IcebergTables[] @@ -589,8 +632,8 @@ namespace storage { bucketName String @map("bucket_name") name String location String - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @map("updated_at") + createdAt Timestamptz @default(now()) @map("created_at") + updatedAt Timestamptz @default(now()) @map("updated_at") remoteTableId String? @map("remote_table_id") shardKey String? @map("shard_key") shardId String? @map("shard_id") @@ -618,14 +661,14 @@ namespace storage { id String @id(map: "buckets_pkey") name String owner Id? - createdAt DateTime? @default(now()) @map("created_at") - updatedAt DateTime? @default(now()) @map("updated_at") + createdAt Timestamptz? @default(now()) @map("created_at") + updatedAt Timestamptz? @default(now()) @map("updated_at") public Boolean? @default(false) avifAutodetection Boolean? @default(false) @map("avif_autodetection") fileSizeLimit BigInt? @map("file_size_limit") allowedMimeTypes String[]? @noCheck(elementNotNull) @map("allowed_mime_types") ownerId String? @map("owner_id") - _type pg.enum(Buckettype) @default(dbgenerated("'STANDARD'::storage.buckettype")) @map("type") + _type pg.enum(Buckettype) @default("STANDARD") @map("type") objects StorageObject[] s3MultipartUploads S3MultipartUploads[] s3MultipartUploadsParts S3MultipartUploadsParts[] @@ -643,7 +686,7 @@ namespace storage { key String version String ownerId String? @map("owner_id") - createdAt DateTime @default(now()) @map("created_at") + createdAt Timestamptz @default(now()) @map("created_at") userMetadata Jsonb? @map("user_metadata") metadata Jsonb? s3MultipartUploadsParts S3MultipartUploadsParts[] @@ -664,7 +707,7 @@ namespace storage { etag String ownerId String? @map("owner_id") version String - createdAt DateTime @default(now()) @map("created_at") + createdAt Timestamptz @default(now()) @map("created_at") bucket StorageBucket @relation(fields: [bucketId], references: [id], map: "s3_multipart_uploads_parts_bucket_id_fkey", index: false) upload S3MultipartUploads @relation(fields: [uploadId], references: [id], onDelete: Cascade, map: "s3_multipart_uploads_parts_upload_id_fkey", index: false) @@ -677,9 +720,9 @@ namespace storage { bucketId String? @map("bucket_id") name String? owner Id? - createdAt DateTime? @default(now()) @map("created_at") - updatedAt DateTime? @default(now()) @map("updated_at") - lastAccessedAt DateTime? @default(now()) @map("last_accessed_at") + createdAt Timestamptz? @default(now()) @map("created_at") + updatedAt Timestamptz? @default(now()) @map("updated_at") + lastAccessedAt Timestamptz? @default(now()) @map("last_accessed_at") metadata Jsonb? pathTokens String[]? @noCheck(elementNotNull) @map("path_tokens") version String? @@ -703,8 +746,8 @@ namespace storage { dimension Int distanceMetric String @map("distance_metric") metadataConfiguration Jsonb? @map("metadata_configuration") - createdAt DateTime @default(now()) @map("created_at") - updatedAt DateTime @default(now()) @map("updated_at") + createdAt Timestamptz @default(now()) @map("created_at") + updatedAt Timestamptz @default(now()) @map("updated_at") bucket BucketsVectors @relation(fields: [bucketId], references: [id], map: "vector_indexes_bucket_id_fkey", index: false) @@index([name, bucketId], map: "vector_indexes_name_bucket_id_idx", unique: true) diff --git a/packages/3-extensions/supabase/test/contract-completeness.test.ts b/packages/3-extensions/supabase/test/contract-completeness.test.ts index 0afd98300b4c..90a0e761343a 100644 --- a/packages/3-extensions/supabase/test/contract-completeness.test.ts +++ b/packages/3-extensions/supabase/test/contract-completeness.test.ts @@ -64,9 +64,102 @@ const AUTH_NATIVE_ENUMS = [ const STORAGE_NATIVE_ENUMS = ['buckettype']; +const NAMED_TYPES = [ + 'CreatedAt', + 'EmailChangeConfirmStatus', + 'Hash', + 'Id', + 'IpAddress', + 'IpAddress2', + 'Name', + 'Parent', + 'Payload', +]; + +/** + * Every `CHECK` the reference fixture declares on an `auth` or `storage` table + * — its 45 `CONSTRAINT … CHECK` names minus `jwt_secret_or_jwt_jwks_required` + * (`_realtime.tenants`) and `subscription_action_filter_check` + * (`realtime.subscription`), both in schemas this pack does not declare. + */ +const CHECK_CONSTRAINTS = [ + 'custom_oauth_providers_authorization_url_https', + 'custom_oauth_providers_authorization_url_length', + 'custom_oauth_providers_client_id_length', + 'custom_oauth_providers_discovery_url_length', + 'custom_oauth_providers_identifier_format', + 'custom_oauth_providers_issuer_length', + 'custom_oauth_providers_jwks_uri_https', + 'custom_oauth_providers_jwks_uri_length', + 'custom_oauth_providers_name_length', + 'custom_oauth_providers_oauth2_requires_endpoints', + 'custom_oauth_providers_oidc_discovery_url_https', + 'custom_oauth_providers_oidc_issuer_https', + 'custom_oauth_providers_oidc_requires_issuer', + 'custom_oauth_providers_provider_type_check', + 'custom_oauth_providers_token_url_https', + 'custom_oauth_providers_token_url_length', + 'custom_oauth_providers_userinfo_url_https', + 'custom_oauth_providers_userinfo_url_length', + 'domain not empty', + 'entity_id not empty', + 'metadata_url not empty', + 'metadata_xml not empty', + 'oauth_authorizations_authorization_code_length', + 'oauth_authorizations_code_challenge_length', + 'oauth_authorizations_expires_at_future', + 'oauth_authorizations_nonce_length', + 'oauth_authorizations_redirect_uri_length', + 'oauth_authorizations_resource_length', + 'oauth_authorizations_scope_length', + 'oauth_authorizations_state_length', + 'oauth_clients_client_name_length', + 'oauth_clients_client_uri_length', + 'oauth_clients_logo_uri_length', + 'oauth_clients_token_endpoint_auth_method_check', + 'oauth_consents_revoked_after_granted', + 'oauth_consents_scopes_length', + 'oauth_consents_scopes_not_empty', + 'one_time_tokens_token_hash_check', + 'request_id not empty', + 'resource_id not empty', + 'sessions_scopes_length', + 'users_email_change_confirm_status_check', + 'webauthn_challenges_challenge_type_check', +]; + +/** Every native-enum column whose live default is a cast, read as the member. */ +const ENUM_LITERAL_DEFAULTS: Readonly> = { + 'auth.oauth_authorizations.response_type': 'code', + 'auth.oauth_authorizations.status': 'pending', + 'auth.oauth_clients.client_type': 'confidential', + 'storage.buckets.type': 'STANDARD', + 'storage.buckets_analytics.type': 'ANALYTICS', + 'storage.buckets_vectors.type': 'VECTOR', +}; + +/** Every list column that waives the derived element-not-null check. */ +const ELEMENT_NOT_NULL_WAIVERS = [ + 'auth.custom_oauth_providers.acceptable_client_ids', + 'auth.custom_oauth_providers.scopes', + 'storage.buckets.allowed_mime_types', + 'storage.objects.path_tokens', +]; + +type ContractJsonColumn = { + codecId: string; + default?: { kind: string; value?: unknown }; + noCheck?: readonly string[]; +}; + +type ContractJsonTable = { + checks?: readonly { name: string }[]; + columns: Record; +}; + type ContractJsonNamespace = { entries: { - table?: Record; + table?: Record; native_enum?: Record; role?: Record; }; @@ -74,6 +167,7 @@ type ContractJsonNamespace = { type ContractJsonStorage = { namespaces: Record; + types?: Record; }; describe('contract completeness — auth/storage table, native enum, and role sets', () => { @@ -102,6 +196,56 @@ describe('contract completeness — auth/storage table, native enum, and role se ); }); + it('declares the nine curated named types', () => { + expect(Object.keys(storage.types ?? {}).sort()).toEqual([...NAMED_TYPES].sort()); + }); + + const declaredColumns = (['auth', 'storage'] as const).flatMap((namespaceId) => + Object.entries(storage.namespaces[namespaceId]?.entries.table ?? {}).flatMap( + ([tableName, table]) => + Object.entries(table.columns).map(([columnName, column]) => ({ + path: `${namespaceId}.${tableName}.${columnName}`, + column, + })), + ), + ); + + it('declares every auth/storage check constraint', () => { + const names = [auth, storageNs].flatMap((namespace) => + Object.values(namespace?.entries.table ?? {}).flatMap((table) => + (table.checks ?? []).map((check) => check.name), + ), + ); + expect(names.sort()).toEqual([...CHECK_CONSTRAINTS].sort()); + }); + + it('reads every native-enum column default as a member literal', () => { + const declared = Object.fromEntries( + declaredColumns + .filter(({ column }) => column.codecId === 'pg/enum@1' && column.default !== undefined) + .map(({ path, column }) => [path, column.default]), + ); + expect(declared).toEqual( + Object.fromEntries( + Object.entries(ENUM_LITERAL_DEFAULTS).map(([path, value]) => [ + path, + { kind: 'literal', value }, + ]), + ), + ); + }); + + it('waives the derived element-not-null check on every list column', () => { + const declared = Object.fromEntries( + declaredColumns + .filter(({ column }) => column.noCheck !== undefined) + .map(({ path, column }) => [path, column.noCheck]), + ); + expect(declared).toEqual( + Object.fromEntries(ELEMENT_NOT_NULL_WAIVERS.map((path) => [path, ['elementNotNull']])), + ); + }); + it('declares the three platform roles under external control', () => { const roles = unboundNs?.entries.role ?? {}; expect(Object.keys(roles).sort()).toEqual([...SupabaseRole.values].sort()); diff --git a/upgrade-instructions/pending/supabase-contract-regenerated/extension/instructions.md b/upgrade-instructions/pending/supabase-contract-regenerated/extension/instructions.md new file mode 100644 index 000000000000..0f42c9c41014 --- /dev/null +++ b/upgrade-instructions/pending/supabase-contract-regenerated/extension/instructions.md @@ -0,0 +1,25 @@ +--- +changes: + - id: supabase-contract-regenerated-from-the-reference-fixture + summary: The Supabase extension contract is regenerated and now declares the reference build's 43 check constraints, six native-enum defaults as member literals, and an element-not-null waiver on two more list columns, so its storage hash changes; re-sign databases that were signed against the previous Supabase contract, and check your own Supabase build declares the same constraints. + detection: + glob: "**/package.json" + contains: + - '"@prisma/orm-extension-supabase"' +--- + +## `supabase-contract-regenerated-from-the-reference-fixture` + +The `@prisma/orm-extension-supabase` contract is now exactly what `contract:generate` produces from the reference fixture, which it had drifted away from. The Supabase space's storage hash changes from `ede079259d126d9153bcb4fc4aa6781d870a255585524e1e95fae9e5af4eef89` to `43f09411473534105017fa715b8932facbdf79feab1bfc75da681beb87f22cbc`. + +Four things changed in the contract. + +**43 check constraints are now declared.** Every `CHECK` that the pack's reference Supabase build declares on an `auth` or `storage` table — for example `users_email_change_confirm_status_check` and `one_time_tokens_token_hash_check` — is now part of the contract. The reference build is supabase/postgres 17.6.1.106 with gotrue 2.188.1 and storage-api 1.54.1. On a database at or near that version the constraints are already present, so `prisma db verify` passes without a schema change. This is the one item that can newly fail for you: the checks used to be a tolerated live extra and are now a declared shape, so if your Supabase build's constraint set differs, verify reports the missing ones. You cannot repair that with a migration, because Prisma emits no DDL against an externally controlled table; report the difference so the pack's reference fixture can be refreshed. + +**Six native-enum column defaults are declared as member literals.** `auth.oauth_clients.client_type`, `auth.oauth_authorizations.response_type`, `auth.oauth_authorizations.status`, and the `type` column of `storage.buckets`, `storage.buckets_analytics` and `storage.buckets_vectors` previously carried the raw cast expression as their default, for example `{ "kind": "function", "expression": "'STANDARD'::storage.buckettype" }`. They now carry the enum member itself: `{ "kind": "literal", "value": "STANDARD" }`. This is the same live default read a more precise way, so the live databases need no change; if you read a column's declared default out of the contract, expect a literal rather than an expression. + +**Two list columns carry an element-not-null waiver.** `auth.custom_oauth_providers.acceptable_client_ids` and `auth.custom_oauth_providers.scopes` now carry `"noCheck": ["elementNotNull"]`, matching the two `storage` list columns that already did. `contract infer` writes this for any list column with no live check at the derived name, and the committed contract is the generator's output, so it carries it too. This item moves the storage hash and changes nothing else you can observe: the pack is under `external` control, so a derived check is stripped before emit whether the waiver is written or not, and `db verify` demanded no such constraint before and demands none now. + +**78 timestamp columns are written as `Timestamptz` instead of `DateTime` in the PSL.** Same codec (`pg/timestamptz-temporal@1`) and same emitted column, so this is a text change only. + +A contract that composes the Supabase space references it by id, so your own `contract.json` and `contract.d.ts` do not change. What changes is the signature: a database that was signed against the previous Supabase contract no longer matches the new hash, so run `prisma db sign` against it after upgrading. If you re-emit your own contract, do that first so the composed space is the new one.