diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 82ad7fb067..c13aa2c69e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -67,6 +67,16 @@ jobs: - name: Check code quality run: pnpm run check:all + # Advisory only (base-vs-head diff, no acceptance artifact to gate a + # required check on) — the hard release-time gate is tracked under + # CLI-2233. `continue-on-error` flags the diff without failing the job; + # the tool's own fetch/unshallow fallback resolves a merge-base from + # this checkout's shallow clone, and skips the compare (exit 0) rather + # than failing when history still can't be resolved. + - name: config type-surface diff (advisory) + continue-on-error: true + run: pnpm run check:config-api + test-unit: if: | !startsWith(github.head_ref, 'release-notes/') && diff --git a/AGENTS.md b/AGENTS.md index cb487a6425..fe598ae530 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -59,7 +59,7 @@ Expected exceptions: Use the `Cli*` prefix for the local checkout side and a bare `Project*` name for the hosted Supabase project. Config-value helpers follow the config family regardless of their inputs (e.g. -`resolveCliConfigValue`, `MissingCliConfigValueError`). A symbol that deliberately spans both +`resolveCliConfigValue`, `CliConfigParseError`). A symbol that deliberately spans both families takes a family-neutral name instead of a misleading prefix (see the ADR 0020 addendum for the `EffectiveConfig` precedent). diff --git a/apps/cli/package.json b/apps/cli/package.json index faa2374194..39fa7ee7b1 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -94,6 +94,7 @@ "smol-toml": "^1.8.0", "tldts": "catalog:", "typescript": "catalog:", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "vitest": "catalog:", "yaml": "^2.9.0" }, diff --git a/apps/cli/scripts/generate-docs.ts b/apps/cli/scripts/generate-docs.ts index 5fa73b3b89..ff5d9f83d6 100644 --- a/apps/cli/scripts/generate-docs.ts +++ b/apps/cli/scripts/generate-docs.ts @@ -1,7 +1,7 @@ -import { mkdirSync, writeFileSync } from "node:fs"; +import { copyFileSync, mkdirSync, writeFileSync } from "node:fs"; import path from "node:path"; import process from "node:process"; -import { CLI_CONFIG_SCHEMA_URL, toCliConfigJsonSchema } from "@supabase/config"; +import { CLI_CONFIG_SCHEMA_URL, PROJECT_CONFIG_SCHEMA_URL } from "@supabase/config"; import { nextRoot } from "../src/next/cli/root.ts"; import { collectCommands, getHelpDoc } from "../src/next/docs/command-docs.ts"; import { formatHelpDocAsMarkdown } from "../src/next/docs/markdown-formatter.ts"; @@ -9,6 +9,7 @@ import { formatHelpDocAsMarkdown } from "../src/next/docs/markdown-formatter.ts" const BINARY_NAME = "supabase"; const defaultContentDir = path.resolve(import.meta.dir, "../../../apps/docs/content/docs/commands"); const defaultDocsPublicDir = path.resolve(import.meta.dir, "../../../apps/docs/public"); +const configPackageDistDir = path.resolve(import.meta.dir, "../../../packages/config/dist"); const contentDir = process.argv[2] ? path.resolve(process.cwd(), process.argv[2]) : defaultContentDir; @@ -72,16 +73,26 @@ function generateCommandDocs() { console.log(`\nGenerated ${pages.length} command page(s)`); } -function generateConfigSchemaAsset() { - const schema = toCliConfigJsonSchema(); - const schemaPathname = new URL(CLI_CONFIG_SCHEMA_URL).pathname.replace(/^\/docs/, ""); - const filePath = path.join(defaultDocsPublicDir, schemaPathname); - - mkdirSync(path.dirname(filePath), { recursive: true }); - writeFileSync(filePath, `${JSON.stringify(schema, null, 2)}\n`); - - console.log(`Generated: ${path.relative(path.resolve(import.meta.dir, "../../.."), filePath)}`); +/** + * Copies `@supabase/config`'s already post-processed (metadata + number- + * union-collapsed) `dist/*.json` schema artifact straight to its docs-site + * public path, rather than re-rendering `toCliConfigJsonSchema()`/ + * `toProjectConfigJsonSchema()` here (CLI-2234) — re-rendering would bypass + * `json-schema-postprocess.ts` and produce a document whose `$id` doesn't + * match what actually gets published. Requires `@supabase/config#build` to + * have already run (wired via `turbo.json`). + */ +function copyConfigSchemaAsset(schemaUrl: string, distFileName: string) { + const schemaPathname = new URL(schemaUrl).pathname.replace(/^\/docs/, ""); + const destPath = path.join(defaultDocsPublicDir, schemaPathname); + const sourcePath = path.join(configPackageDistDir, distFileName); + + mkdirSync(path.dirname(destPath), { recursive: true }); + copyFileSync(sourcePath, destPath); + + console.log(`Generated: ${path.relative(path.resolve(import.meta.dir, "../../.."), destPath)}`); } generateCommandDocs(); -generateConfigSchemaAsset(); +copyConfigSchemaAsset(CLI_CONFIG_SCHEMA_URL, "schema.json"); +copyConfigSchemaAsset(PROJECT_CONFIG_SCHEMA_URL, "project-schema.json"); diff --git a/apps/cli/src/legacy/commands/config/push/push.handler.ts b/apps/cli/src/legacy/commands/config/push/push.handler.ts index ba0004663c..f493bddec9 100644 --- a/apps/cli/src/legacy/commands/config/push/push.handler.ts +++ b/apps/cli/src/legacy/commands/config/push/push.handler.ts @@ -1,5 +1,6 @@ import { dirname } from "node:path"; -import { findCliProjectRoot, loadCliConfig } from "@supabase/config/effect"; +import { findCliProjectRoot } from "@supabase/config/effect"; +import { loadCliConfig } from "@supabase/config/internal"; import { Effect, FileSystem, Path } from "effect"; import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; diff --git a/apps/cli/src/legacy/commands/functions/new/new.handler.ts b/apps/cli/src/legacy/commands/functions/new/new.handler.ts index 1531f5df53..2612206fc3 100644 --- a/apps/cli/src/legacy/commands/functions/new/new.handler.ts +++ b/apps/cli/src/legacy/commands/functions/new/new.handler.ts @@ -1,4 +1,4 @@ -import { loadCliConfig } from "@supabase/config/effect"; +import { loadCliConfig } from "@supabase/config/internal"; import { defaultPublishableKey } from "@supabase/stack/effect"; import { Effect, FileSystem, Option, Path } from "effect"; diff --git a/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts index a94a349ded..672df4a18a 100644 --- a/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts +++ b/apps/cli/src/legacy/commands/gen/gen.signing-keys-config.ts @@ -1,4 +1,5 @@ -import { loadCliConfig, loadCliProjectEnvironment } from "@supabase/config/effect"; +import { loadCliProjectEnvironment } from "@supabase/config/effect"; +import { loadCliConfig } from "@supabase/config/internal"; import { Effect, FileSystem, Option, Path } from "effect"; import { legacyAssertDecodableJwkAlgorithm } from "../../shared/legacy-go-jwt.ts"; import { legacyGoJsonKindName } from "../../shared/legacy-go-json.ts"; diff --git a/apps/cli/src/legacy/commands/gen/types/types.handler.ts b/apps/cli/src/legacy/commands/gen/types/types.handler.ts index 63a47aa0fd..4be9f7ad24 100644 --- a/apps/cli/src/legacy/commands/gen/types/types.handler.ts +++ b/apps/cli/src/legacy/commands/gen/types/types.handler.ts @@ -1,4 +1,4 @@ -import { loadCliConfig } from "@supabase/config/effect"; +import { loadCliConfig } from "@supabase/config/internal"; import { ChildProcessSpawner } from "effect/unstable/process"; import { Effect, FileSystem, Option, Path, Stdio, Stream } from "effect"; import { diff --git a/apps/cli/src/legacy/commands/secrets/set/set.handler.ts b/apps/cli/src/legacy/commands/secrets/set/set.handler.ts index 104406cb58..9184400567 100644 --- a/apps/cli/src/legacy/commands/secrets/set/set.handler.ts +++ b/apps/cli/src/legacy/commands/secrets/set/set.handler.ts @@ -1,11 +1,10 @@ import { - loadCliConfig, loadCliProjectEnvironment, CliConfigSchema, - resolveCliConfigSubtree, type CliConfig, type CliConfigParseError, } from "@supabase/config/effect"; +import { loadCliConfig, resolveCliConfigSubtree } from "@supabase/config/internal"; import { V1BulkCreateSecretsInput } from "@supabase/api/effect"; import { parse as parseDotenv } from "dotenv"; import { Effect, FileSystem, Option, Path, Redacted, Schema } from "effect"; diff --git a/apps/cli/src/legacy/commands/start/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index 2d6034999d..a38d50ecf1 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -2,7 +2,8 @@ * Native TS implementation of `start` — see `SIDE_EFFECTS.md` for the full * behavior contract. */ -import { inferFunctionsManifest, resolveCliConfigSubtree } from "@supabase/config/effect"; +import { inferFunctionsManifest } from "@supabase/config/effect"; +import { resolveCliConfigSubtree } from "@supabase/config/internal"; import { Effect, FileSystem, Option, Path, Result } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; diff --git a/apps/cli/src/legacy/commands/storage/storage.frame.ts b/apps/cli/src/legacy/commands/storage/storage.frame.ts index ce203e442f..1d187e23c3 100644 --- a/apps/cli/src/legacy/commands/storage/storage.frame.ts +++ b/apps/cli/src/legacy/commands/storage/storage.frame.ts @@ -1,9 +1,5 @@ -import { - loadCliConfig, - type LoadCliConfigOptions, - CliConfigSchema, - type CliConfig, -} from "@supabase/config/effect"; +import { CliConfigSchema, type CliConfig } from "@supabase/config/effect"; +import { loadCliConfig, type InternalLoadCliConfigOptions } from "@supabase/config/internal"; import { Effect, Schema } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; @@ -49,7 +45,7 @@ export const legacyLoadStorageConfig = Effect.fnUntraced(function* ( workdir: string, projectRef: string, ) { - const loadOptions: LoadCliConfigOptions = + const loadOptions: InternalLoadCliConfigOptions = projectRef !== "" ? { projectRef, goViperCompat: true } : { goViperCompat: true }; const loaded = yield* loadCliConfig(workdir, loadOptions).pipe( Effect.catchTag( diff --git a/packages/config/src/tls.ts b/apps/cli/src/legacy/shared/kong-local-ca-cert.ts similarity index 100% rename from packages/config/src/tls.ts rename to apps/cli/src/legacy/shared/kong-local-ca-cert.ts diff --git a/packages/config/src/tls.unit.test.ts b/apps/cli/src/legacy/shared/kong-local-ca-cert.unit.test.ts similarity index 81% rename from packages/config/src/tls.unit.test.ts rename to apps/cli/src/legacy/shared/kong-local-ca-cert.unit.test.ts index 1f266f5d80..cbe030bcd5 100644 --- a/packages/config/src/tls.unit.test.ts +++ b/apps/cli/src/legacy/shared/kong-local-ca-cert.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { KONG_LOCAL_CA_CERT } from "./tls.ts"; +import { KONG_LOCAL_CA_CERT } from "./kong-local-ca-cert.ts"; describe("KONG_LOCAL_CA_CERT", () => { it("is a non-empty PEM certificate", () => { diff --git a/apps/cli/src/legacy/shared/legacy-local-config-values.ts b/apps/cli/src/legacy/shared/legacy-local-config-values.ts index 4b6e4042b8..68b8985419 100644 --- a/apps/cli/src/legacy/shared/legacy-local-config-values.ts +++ b/apps/cli/src/legacy/shared/legacy-local-config-values.ts @@ -1,7 +1,8 @@ import { readFileSync } from "node:fs"; import { basename } from "node:path"; -import { ENV_CAPTURE_REGEX, type CliConfig } from "@supabase/config"; +import type { CliConfig } from "@supabase/config"; +import { ENV_CAPTURE_REGEX } from "@supabase/config/internal"; import { defaultJwtSecret, defaultPublishableKey, defaultSecretKey } from "@supabase/stack/effect"; import { Schema } from "effect"; diff --git a/apps/cli/src/legacy/shared/legacy-local-project-context.ts b/apps/cli/src/legacy/shared/legacy-local-project-context.ts index 0a9a46dc71..01cfbf6fc7 100644 --- a/apps/cli/src/legacy/shared/legacy-local-project-context.ts +++ b/apps/cli/src/legacy/shared/legacy-local-project-context.ts @@ -1,10 +1,10 @@ import { - loadCliConfig, loadCliProjectEnvironment, CliConfigSchema, type LoadedCliConfig, type CliConfig, } from "@supabase/config/effect"; +import { loadCliConfig } from "@supabase/config/internal"; import { Effect, FileSystem, Path, Schema } from "effect"; import { LEGACY_BITBUCKET_CLONE_DIR_ENV_KEY } from "./legacy-bitbucket-pipeline.ts"; diff --git a/apps/cli/src/legacy/shared/legacy-seed-buckets.ts b/apps/cli/src/legacy/shared/legacy-seed-buckets.ts index afa752df64..383703b62a 100644 --- a/apps/cli/src/legacy/shared/legacy-seed-buckets.ts +++ b/apps/cli/src/legacy/shared/legacy-seed-buckets.ts @@ -1,9 +1,5 @@ -import { - loadCliConfig, - type LoadCliConfigOptions, - type CliConfig, - CliConfigSchema, -} from "@supabase/config/effect"; +import { type CliConfig, CliConfigSchema } from "@supabase/config/effect"; +import { loadCliConfig, type InternalLoadCliConfigOptions } from "@supabase/config/internal"; import { Effect, FileSystem, Path, Schema } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import type { PlatformError } from "effect/PlatformError"; @@ -184,7 +180,7 @@ export const legacySeedBucketsRun = Effect.fnUntraced(function* (opts: { // --linked. A parse failure aborts before any network call. Skipped entirely // when the caller already supplied `resolvedConfig` — see that option's doc // comment above. - const loadOptions: LoadCliConfigOptions = + const loadOptions: InternalLoadCliConfigOptions = projectRef !== "" ? { projectRef, goViperCompat: true } : { goViperCompat: true }; const loaded = opts.resolvedConfig !== undefined diff --git a/apps/cli/src/legacy/shared/legacy-storage-credentials.ts b/apps/cli/src/legacy/shared/legacy-storage-credentials.ts index 59cf625f09..07f3ce74d9 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-credentials.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-credentials.ts @@ -1,4 +1,3 @@ -import { KONG_LOCAL_CA_CERT } from "@supabase/config"; import { defaultJwtSecret, generateJwt } from "@supabase/stack/effect"; import { Effect, FileSystem, Path } from "effect"; @@ -7,6 +6,7 @@ import { LegacyCliSettings } from "../config/legacy-cli-settings.service.ts"; import { legacyResolveApiExternalUrl } from "./legacy-api-url.ts"; import { legacyMapTenantApiKeysError } from "./legacy-get-tenant-api-keys.ts"; import { legacyGetHostname } from "./legacy-hostname.ts"; +import { KONG_LOCAL_CA_CERT } from "./kong-local-ca-cert.ts"; import { legacyExtractServiceKeys } from "./legacy-tenant-keys.ts"; import { LegacyStorageApiKeysNetworkError, diff --git a/apps/cli/src/shared/config/project-config-api-drift.unit.test.ts b/apps/cli/src/shared/config/project-config-api-drift.unit.test.ts index 4998105326..8c3cdc6f77 100644 --- a/apps/cli/src/shared/config/project-config-api-drift.unit.test.ts +++ b/apps/cli/src/shared/config/project-config-api-drift.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { V2GetProjectConfigOutput } from "@supabase/api/effect"; -import { toProjectConfig, type ProjectConfigApiAttributes } from "@supabase/config"; +import { toProjectConfig } from "@supabase/config"; +import type { ProjectConfigApiAttributes } from "@supabase/config/internal"; /** * Compile-time drift guards (CLI-2230 design requirement): `@supabase/config` diff --git a/apps/cli/src/shared/config/project-config-auth-contract.unit.test.ts b/apps/cli/src/shared/config/project-config-auth-contract.unit.test.ts index 4e03078143..a92b1a091a 100644 --- a/apps/cli/src/shared/config/project-config-auth-contract.unit.test.ts +++ b/apps/cli/src/shared/config/project-config-auth-contract.unit.test.ts @@ -4,7 +4,7 @@ import { projectConfigMappingRows, unmappedSecretApiPaths, type ProjectConfigMappingRow, -} from "@supabase/config"; +} from "@supabase/config/internal"; /** * Contract-derived auth guard (CLI-2230's residual review): closes two gaps @@ -27,9 +27,10 @@ import { * package must stay decoupled so it can publish to npm independently), so * this guard lives in `apps/cli`, which can import both. It needs `@supabase/ * config`'s row data and orphan-secret list at runtime, which is why - * `projectConfigMappingRows`/`unmappedSecretApiPaths` are exported from the - * package root (`packages/config/src/index.ts`) — otherwise-internal registry - * data, exposed solely so this cross-package guard can walk it. + * `projectConfigMappingRows`/`unmappedSecretApiPaths` are exported from + * `@supabase/config/internal` (`packages/config/src/internal.ts`) — + * otherwise-internal registry data, exposed solely so this cross-package + * guard (and `apps/cli`'s own contract tests) can walk it. * * `V1GetAuthServiceConfigOutput` (not the v2 project-config resource) is the * authority here: it is the generated schema whose field names are the real, diff --git a/apps/cli/src/shared/config/project-config-presence-parity.unit.test.ts b/apps/cli/src/shared/config/project-config-presence-parity.unit.test.ts index 0dd15d541e..b7e936241c 100644 --- a/apps/cli/src/shared/config/project-config-presence-parity.unit.test.ts +++ b/apps/cli/src/shared/config/project-config-presence-parity.unit.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { Schema } from "effect"; -import { AUTH_HOOK_NAMES, CliConfigSchema, fromConfigDocument } from "@supabase/config"; +import { CliConfigSchema, fromConfigDocument } from "@supabase/config"; +import { AUTH_HOOK_NAMES } from "@supabase/config/internal"; import { legacyPresenceIn, type LegacyConfigPushPresence, diff --git a/apps/cli/src/shared/functions/functions-config.ts b/apps/cli/src/shared/functions/functions-config.ts index 2c5cbaf52a..0c578eda46 100644 --- a/apps/cli/src/shared/functions/functions-config.ts +++ b/apps/cli/src/shared/functions/functions-config.ts @@ -1,6 +1,7 @@ import { basename } from "node:path"; import { Effect, type FileSystem, type Path } from "effect"; -import { loadCliConfig, type LoadedCliConfig } from "@supabase/config/effect"; +import type { LoadedCliConfig } from "@supabase/config/effect"; +import { loadCliConfig } from "@supabase/config/effect"; import { normalizeProjectId } from "./functions-docker.ts"; /** @@ -61,10 +62,10 @@ export const loadFunctionsCliConfig = Effect.fnUntraced(function* (input: { readonly goConfigCompat: FunctionsGoConfigCompat | undefined; }) { if (input.goConfigCompat === undefined) { - const loaded = yield* loadCliConfig(input.projectRoot, { - ...(input.projectRef === undefined ? {} : { projectRef: input.projectRef }), - goViperCompat: false, - }); + const loaded = yield* loadCliConfig( + input.projectRoot, + input.projectRef === undefined ? {} : { projectRef: input.projectRef }, + ); return { loaded, projectEnvValues: undefined, diff --git a/apps/cli/src/shared/functions/serve.ts b/apps/cli/src/shared/functions/serve.ts index a763e2ae78..eeccfc21a0 100644 --- a/apps/cli/src/shared/functions/serve.ts +++ b/apps/cli/src/shared/functions/serve.ts @@ -2,14 +2,16 @@ import { CliConfigSchema, findCliProjectPaths, inferFunctionsManifest, - loadCliConfig, - resolveCliConfigSubtree, - resolveCliConfigValue, type CliConfig, type CliProjectEnvironment, type ResolvedCliConfigValue, type ResolvedFunctionConfig as ManifestFunctionConfig, } from "@supabase/config/effect"; +import { + loadCliConfig, + resolveCliConfigSubtree, + resolveCliConfigValue, +} from "@supabase/config/internal"; import { defaultJwtSecret, defaultPublishableKey, diff --git a/apps/cli/src/shared/output/normalize-error.unit.test.ts b/apps/cli/src/shared/output/normalize-error.unit.test.ts index 68ac197786..6adcdb132f 100644 --- a/apps/cli/src/shared/output/normalize-error.unit.test.ts +++ b/apps/cli/src/shared/output/normalize-error.unit.test.ts @@ -1,11 +1,7 @@ import { describe, expect, test } from "vitest"; import { Cause } from "effect"; import { CliError, Command } from "effect/unstable/cli"; -import { - CliConfigParseError, - CliProjectEnvParseError, - MissingCliConfigValueError, -} from "@supabase/config"; +import { CliConfigParseError, CliProjectEnvParseError } from "@supabase/config"; import { legacyBranchesCommand } from "../../legacy/commands/branches/branches.command.ts"; import { legacyNetworkRestrictionsCommand } from "../../legacy/commands/network-restrictions/network-restrictions.command.ts"; import { CliProjectHomeNotDirectoryError } from "../../next/config/cli-project-home.service.ts"; @@ -214,17 +210,6 @@ describe("normalizeCliError", () => { }); }); - test("MissingCliConfigValueError falls back to its bare tag as both code and message", () => { - const error = new MissingCliConfigValueError({ - configPath: "project_id", - }); - - expect(normalizeCliError(error)).toEqual({ - code: "MissingCliConfigValueError", - message: "MissingCliConfigValueError", - }); - }); - test("CliProjectHomeNotDirectoryError surfaces its tag as code with its own message", () => { const error = new CliProjectHomeNotDirectoryError({ message: ".supabase could not be created: a file exists at that path", diff --git a/apps/cli/src/shared/telemetry/error-actionability.ts b/apps/cli/src/shared/telemetry/error-actionability.ts index 777a1a5f7b..0dc1c6d9ce 100644 --- a/apps/cli/src/shared/telemetry/error-actionability.ts +++ b/apps/cli/src/shared/telemetry/error-actionability.ts @@ -940,7 +940,6 @@ const externalActionabilityByTag: Record = { // @supabase/config CliConfigParseError: () => actionability.invalidConfig, CliProjectEnvParseError: () => actionability.invalidConfig, - MissingCliConfigValueError: () => actionability.invalidConfig, DuplicateRemoteProjectIdError: () => actionability.invalidConfig, InvalidRemoteProjectIdError: () => actionability.invalidConfig, // A Management API project-config response that fails to map is a platform diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index 50b81a2098..fe40faa8e6 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -1,4 +1,25 @@ { "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + // Lets `tsc` resolve `@supabase/config`'s exports-map `bun` condition + // straight to its `src/*.ts` sources (self-typed, no `.d.ts` needed) + // instead of `dist/*.d.ts`, which requires that package to be built + // first — see `packages/config/package.json`'s exports map and + // `packages/config/AGENTS.md`'s "Build" section (CLI-2234). This also + // affects any OTHER dependency whose own exports map declares a `bun` + // condition (e.g. `@supabase/pg-topo`) — see this package's AGENTS.md/PR + // notes for a known collision that surfaces there. + "customConditions": ["bun"], + // `@supabase/pg-topo` (external, from supabase/pg-toolbelt) also declares + // a `bun` exports condition, pointing at its UNBUILT `src/*.ts`, which + // carries type errors at 1.0.0-alpha.5 that its shipped `dist/*.d.ts` + // does not surface. `paths` wins over exports-condition resolution, so + // pin its types to the published declarations. Drop this once a fixed + // pg-topo release (>= 1.0.0-alpha.6) clears the pnpm minimumReleaseAge + // window and is bumped in this package. + "paths": { + "@supabase/pg-topo": ["./node_modules/@supabase/pg-topo/dist/index.d.ts"] + } + }, "exclude": ["supabase", "src/shared/workers/stacks"] } diff --git a/apps/cli/vitest.config.ts b/apps/cli/vitest.config.ts index 9c1578d853..992e5a6580 100644 --- a/apps/cli/vitest.config.ts +++ b/apps/cli/vitest.config.ts @@ -1,4 +1,5 @@ import { readFileSync } from "node:fs"; +import { defaultClientConditions, defaultServerConditions } from "vite"; import { defineConfig } from "vitest/config"; function dockerfileTextPlugin() { @@ -15,7 +16,22 @@ function dockerfileTextPlugin() { }; } +// Workspace packages such as @supabase/config publish a `bun` export +// condition pointing at their TypeScript source (see +// packages/config/package.json's `exports` map); without it, Vite's resolver +// falls through to the `default` condition and loads the built `dist/*.js` +// output instead — which is stale, or missing entirely on a fresh clone +// before the package has been built. Extending (not replacing) Vite's +// default condition lists keeps every other package's exports resolution +// unchanged. Required on every inline `test.projects` entry below too: +// Vitest builds a separate Vite config per project and does not inherit +// these from the root config (see PR #6366 finding 0). +const workspacePackageResolve = { conditions: [...defaultClientConditions, "bun"] }; +const workspacePackageSsrResolve = { conditions: [...defaultServerConditions, "bun"] }; + export default defineConfig({ + resolve: workspacePackageResolve, + ssr: { resolve: workspacePackageSsrResolve }, plugins: [dockerfileTextPlugin()], test: { passWithNoTests: true, @@ -41,6 +57,8 @@ export default defineConfig({ }, projects: [ { + resolve: workspacePackageResolve, + ssr: { resolve: workspacePackageSsrResolve }, plugins: [dockerfileTextPlugin()], test: { name: "unit", @@ -49,6 +67,8 @@ export default defineConfig({ }, }, { + resolve: workspacePackageResolve, + ssr: { resolve: workspacePackageSsrResolve }, plugins: [dockerfileTextPlugin()], test: { name: "integration", @@ -56,6 +76,8 @@ export default defineConfig({ }, }, { + resolve: workspacePackageResolve, + ssr: { resolve: workspacePackageSsrResolve }, plugins: [dockerfileTextPlugin()], test: { name: "e2e", @@ -69,6 +91,8 @@ export default defineConfig({ }, }, { + resolve: workspacePackageResolve, + ssr: { resolve: workspacePackageSsrResolve }, plugins: [dockerfileTextPlugin()], test: { // Live tests run against one provisioned project on the configured diff --git a/apps/docs/public/cli/config.schema.json b/apps/docs/public/cli/config.schema.json index 71eb17061d..32f2fe9e42 100644 --- a/apps/docs/public/cli/config.schema.json +++ b/apps/docs/public/cli/config.schema.json @@ -1,5 +1,8 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://supabase.com/docs/cli/config.schema.json", + "title": "Supabase CLI config (CliConfig)", + "description": "The Supabase CLI's local project config document (supabase/config.toml or supabase/config.json).", "type": "object", "properties": { "project_id": { @@ -15,33 +18,19 @@ "default": true }, "port": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] + "type": "number", + "description": "Port to the local Logflare service.", + "default": 54327 }, "backend": { "type": "string", - "enum": [ - "postgres", - "bigquery" - ], + "enum": ["postgres", "bigquery"], "description": "Configure one of the supported backends:\n\n- `postgres`\n- `bigquery`", "default": "postgres" }, "vector_port": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] + "type": "number", + "description": "Port to the local syslog ingest service." }, "gcp_project_id": { "type": "string", @@ -67,37 +56,53 @@ "default": true }, "port": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] + "type": "number", + "description": "Port to use for the API URL.", + "default": 54321 }, "schemas": { - "$ref": "#/$defs/Arrays_" + "type": "array", + "items": { + "type": "string", + "description": "Schemas to expose in your API. Tables, views and stored procedures in this schema will get API endpoints." + }, + "default": ["public", "graphql_public"] }, "extra_search_path": { - "$ref": "#/$defs/Arrays_1" + "type": "array", + "items": { + "type": "string", + "description": "Extra schemas to add to the search_path of every request." + }, + "default": ["public", "extensions"] }, "max_rows": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] + "type": "number", + "description": "The maximum number of rows returned from a view, table, or stored procedure. Limits payload size for accidental or malicious requests.", + "default": 1000 }, "auto_expose_new_tables": { "type": "boolean", "description": "Controls whether newly-created tables, views, sequences and functions in the `public` schema by `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`) without explicit GRANTs. When unset, new entities are auto-exposed, matching the cloud default. Set to `false` to revoke the default Data API privileges so new entities require explicit GRANTs, matching a cloud project with the \"Default privileges for new entities\" toggle turned off." }, "tls": { - "$ref": "#/$defs/Objects_" + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable HTTPS endpoints locally using a self-signed certificate.", + "default": false + }, + "cert_path": { + "type": "string", + "description": "Path to the self-signed certificate." + }, + "key_path": { + "type": "string", + "description": "Path to the self-signed certificate private key." + } + }, + "additionalProperties": false }, "external_url": { "type": "string", @@ -120,17 +125,18 @@ "default": "http://127.0.0.1:3000" }, "additional_redirect_urls": { - "$ref": "#/$defs/Arrays_2" + "type": "array", + "items": { + "type": "string", + "description": "A URL that auth providers are permitted to redirect to." + }, + "description": "A list of exact URLs that auth providers are permitted to redirect to post authentication.", + "default": ["https://127.0.0.1:3000"] }, "jwt_expiry": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] + "type": "number", + "description": "How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one week).", + "default": 3600 }, "jwt_issuer": { "type": "string", @@ -146,14 +152,9 @@ "default": true }, "refresh_token_reuse_interval": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] + "type": "number", + "description": "Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.", + "default": 10 }, "enable_manual_linking": { "type": "boolean", @@ -171,17 +172,20 @@ "default": false }, "minimum_password_length": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] + "type": "number", + "description": "Passwords shorter than this value will be rejected as weak.", + "default": 6 }, "password_requirements": { - "$ref": "#/$defs/Union_1" + "type": "string", + "enum": [ + "", + "letters_digits", + "lower_upper_letters_digits", + "lower_upper_letters_digits_symbols" + ], + "description": "Password character requirements.", + "default": "" }, "publishable_key": { "type": "string", @@ -204,2618 +208,2061 @@ "description": "Service role key override." }, "rate_limit": { - "$ref": "#/$defs/Objects_1" + "type": "object", + "properties": { + "email_sent": { + "type": "number", + "description": "Number of emails that can be sent per hour.", + "default": 2 + }, + "sms_sent": { + "type": "number", + "description": "Number of SMS messages that can be sent per hour.", + "default": 30 + }, + "anonymous_users": { + "type": "number", + "description": "Number of anonymous sign-ins that can be made per hour per IP address.", + "default": 30 + }, + "token_refresh": { + "type": "number", + "description": "Number of sessions that can be refreshed in a 5 minute interval per IP address.", + "default": 150 + }, + "sign_in_sign_ups": { + "type": "number", + "description": "Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address.", + "default": 30 + }, + "token_verifications": { + "type": "number", + "description": "Number of OTP or magic link verifications that can be made in a 5 minute interval per IP address.", + "default": 30 + }, + "web3": { + "type": "number", + "description": "Number of Web3 logins that can be made in a 5 minute interval per IP address.", + "default": 30 + } + }, + "additionalProperties": false }, "captcha": { - "$ref": "#/$defs/Objects_2" + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable CAPTCHA verification.", + "default": false + }, + "provider": { + "type": "string", + "enum": ["hcaptcha", "turnstile"], + "description": "CAPTCHA provider to use." + }, + "secret": { + "type": "string", + "description": "Secret key for the CAPTCHA provider." + } + }, + "additionalProperties": false }, "hook": { - "$ref": "#/$defs/Objects_3" - }, - "mfa": { - "$ref": "#/$defs/Objects_4" - }, - "sessions": { - "$ref": "#/$defs/Objects_5" - }, - "email": { - "$ref": "#/$defs/Objects_6" - }, - "sms": { - "$ref": "#/$defs/Objects_7" - }, - "external": { - "$ref": "#/$defs/Objects_8" - }, - "web3": { - "$ref": "#/$defs/Objects_9" - }, - "oauth_server": { - "$ref": "#/$defs/Objects_11" - }, - "third_party": { - "$ref": "#/$defs/Objects_12" - } - }, - "additionalProperties": false - }, - "db": { - "type": "object", - "properties": { - "port": { - "anyOf": [ - { - "type": "number" + "type": "object", + "properties": { + "mfa_verification_attempt": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable the mfa verification hook.", + "default": false + }, + "uri": { + "type": "string", + "description": "The URI of the postgres function or HTTP endpoint to call." + }, + "secrets": { + "type": "string", + "description": "Secret value to pass to the function or endpoint." + } + }, + "additionalProperties": false }, - { - "$ref": "#/$defs/Union_" + "password_verification_attempt": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable the password verification hook.", + "default": false + }, + "uri": { + "type": "string", + "description": "The URI of the postgres function or HTTP endpoint to call." + }, + "secrets": { + "type": "string", + "description": "Secret value to pass to the function or endpoint." + } + }, + "additionalProperties": false + }, + "custom_access_token": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable the custom access token hook.", + "default": false + }, + "uri": { + "type": "string", + "description": "The URI of the postgres function or HTTP endpoint to call." + }, + "secrets": { + "type": "string", + "description": "Secret value to pass to the function or endpoint." + } + }, + "additionalProperties": false + }, + "send_sms": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable the send sms hook.", + "default": false + }, + "uri": { + "type": "string", + "description": "The URI of the postgres function or HTTP endpoint to call." + }, + "secrets": { + "type": "string", + "description": "Secret value to pass to the function or endpoint." + } + }, + "additionalProperties": false + }, + "send_email": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable the send email hook.", + "default": false + }, + "uri": { + "type": "string", + "description": "The URI of the postgres function or HTTP endpoint to call." + }, + "secrets": { + "type": "string", + "description": "Secret value to pass to the function or endpoint." + } + }, + "additionalProperties": false + }, + "before_user_created": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable the before user created hook.", + "default": false + }, + "uri": { + "type": "string", + "description": "The URI of the postgres function or HTTP endpoint to call." + }, + "secrets": { + "type": "string", + "description": "Secret value to pass to the function or endpoint." + } + }, + "additionalProperties": false } - ] + }, + "additionalProperties": false }, - "shadow_port": { - "anyOf": [ - { - "type": "number" + "mfa": { + "type": "object", + "properties": { + "totp": { + "type": "object", + "properties": { + "enroll_enabled": { + "type": "boolean", + "description": "Allow/disallow TOTP enrollment for users.", + "default": false + }, + "verify_enabled": { + "type": "boolean", + "description": "Allow/disallow TOTP verification for users.", + "default": false + } + }, + "additionalProperties": false }, - { - "$ref": "#/$defs/Union_" + "phone": { + "type": "object", + "properties": { + "enroll_enabled": { + "type": "boolean", + "description": "Allow/disallow phone enrollment for users.", + "default": false + }, + "verify_enabled": { + "type": "boolean", + "description": "Allow/disallow phone verification for users.", + "default": false + }, + "otp_length": { + "type": "number", + "description": "The length of the OTP code.", + "default": 6 + }, + "template": { + "type": "string", + "description": "The template to use for the phone message.", + "default": "Your code is {{ .Code }}" + }, + "max_frequency": { + "type": "string", + "description": "The maximum frequency of the phone messages.", + "default": "5s" + } + }, + "additionalProperties": false + }, + "web_authn": { + "type": "object", + "properties": { + "enroll_enabled": { + "type": "boolean", + "description": "Allow/disallow WebAuthn enrollment for users.", + "default": false + }, + "verify_enabled": { + "type": "boolean", + "description": "Allow/disallow WebAuthn verification for users.", + "default": false + } + }, + "additionalProperties": false + }, + "max_enrolled_factors": { + "type": "number", + "description": "The maximum number of MFA factors a user can enroll in.", + "default": 10 } - ] - }, - "health_timeout": { - "type": "string", - "description": "Maximum amount of time to wait for health check when starting the local database.", - "default": "2m" + }, + "additionalProperties": false }, - "major_version": { - "anyOf": [ - { - "type": "number" + "sessions": { + "type": "object", + "properties": { + "timebox": { + "type": "string", + "description": "The timebox for the user session." }, - { - "$ref": "#/$defs/Union_" + "inactivity_timeout": { + "type": "string", + "description": "The inactivity timeout for the user session." } - ] - }, - "pooler": { - "$ref": "#/$defs/Objects_13" - }, - "migrations": { - "$ref": "#/$defs/Objects_14" - }, - "seed": { - "$ref": "#/$defs/Objects_15" - }, - "settings": { - "$ref": "#/$defs/Objects_16" - }, - "network_restrictions": { - "$ref": "#/$defs/Objects_17" - }, - "ssl_enforcement": { - "$ref": "#/$defs/Objects_18" - }, - "vault": { - "$ref": "#/$defs/Objects_19" - } - }, - "additionalProperties": false - }, - "edge_runtime": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable the local Edge Runtime service.", - "default": true - }, - "policy": { - "type": "string", - "enum": [ - "oneshot", - "per_worker" - ], - "description": "Configure the supported request policy.", - "default": "per_worker" + }, + "additionalProperties": false, + "default": {} }, - "inspector_port": { - "anyOf": [ - { - "type": "number" + "email": { + "type": "object", + "properties": { + "enable_signup": { + "type": "boolean", + "description": "Allow/disallow new user signups via email to your project.", + "default": true }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "deno_version": { - "anyOf": [ - { - "type": "number" + "double_confirm_changes": { + "type": "boolean", + "description": "If enabled, a user will be required to confirm any email change on both the old and new email addresses.", + "default": true }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "secrets": { - "$ref": "#/$defs/Objects_20" - } - }, - "additionalProperties": false - }, - "functions": { - "anyOf": [ - { - "$ref": "#/$defs/Objects_21" - }, - { - "type": "null" - } - ] - }, - "local_smtp": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable the local SMTP testing server.", - "default": true - }, - "port": { - "anyOf": [ - { - "type": "number" + "enable_confirmations": { + "type": "boolean", + "description": "If enabled, users need to confirm their email address before signing in.", + "default": false }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "smtp_port": { - "anyOf": [ - { - "type": "number" + "secure_password_change": { + "type": "boolean", + "description": "If enabled, users will need to reauthenticate or have logged in recently to change their password.", + "default": false }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "pop3_port": { - "anyOf": [ - { - "type": "number" + "max_frequency": { + "type": "string", + "description": "Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.", + "default": "1s" }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "admin_email": { - "type": "string", - "description": "Admin email address for test email sender metadata." - }, - "sender_name": { - "type": "string", - "description": "Sender name for test email sender metadata." - } - }, - "additionalProperties": false - }, - "realtime": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable the local Realtime service.", - "default": true - }, - "ip_version": { - "type": "string", - "enum": [ - "IPv4", - "IPv6" - ], - "description": "Bind realtime via either IPv4 or IPv6.", - "default": "IPv4" - }, - "max_header_length": { - "anyOf": [ - { - "type": "number" + "otp_length": { + "type": "number", + "description": "Number of characters used in the email OTP.", + "default": 6 }, - { - "$ref": "#/$defs/Union_" - } - ] - } - }, - "additionalProperties": false - }, - "storage": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable the local Storage service.", - "default": true - }, - "file_size_limit": { - "anyOf": [ - { - "type": "string" + "otp_expiry": { + "type": "number", + "description": "Number of seconds before the email OTP expires.", + "default": 3600 }, - { + "smtp": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable SMTP for email delivery.", + "default": false + }, + "host": { + "type": "string", + "description": "Hostname or IP address of the SMTP server." + }, + "port": { + "type": "number", + "description": "Port number of the SMTP server." + }, + "user": { + "type": "string", + "description": "Username for authenticating with the SMTP server." + }, + "pass": { + "type": "string", + "description": "Password for authenticating with the SMTP server." + }, + "admin_email": { + "type": "string", + "description": "Email used as the sender for emails sent from the application." + }, + "sender_name": { + "type": "string", + "description": "Display name used as the sender for emails sent from the application." + } + }, + "additionalProperties": false + }, + "template": { "anyOf": [ { - "type": "number" + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "subject": { + "type": "string", + "description": "Subject line for the email template.", + "default": "" + }, + "content_path": { + "type": "string", + "description": "Path to the HTML template.", + "default": "" + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "description": "Custom email template configuration.", + "default": {} }, { - "$ref": "#/$defs/Union_" + "type": "null" } ] - } - ] - }, - "image_transformation": { - "$ref": "#/$defs/Objects_22" - }, - "buckets": { - "$ref": "#/$defs/Objects_23" - }, - "s3_protocol": { - "$ref": "#/$defs/Objects_24" - }, - "analytics": { - "$ref": "#/$defs/Objects_25" - }, - "vector": { - "$ref": "#/$defs/Objects_26" - } - }, - "additionalProperties": false - }, - "studio": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable the local Supabase Studio dashboard.", - "default": true - }, - "port": { - "anyOf": [ - { - "type": "number" }, - { - "$ref": "#/$defs/Union_" + "notification": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the notification email.", + "default": false + }, + "subject": { + "type": "string", + "description": "Subject line for the notification email.", + "default": "" + }, + "content_path": { + "type": "string", + "description": "Path to the HTML notification template.", + "default": "" + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "description": "Notification email configuration.", + "default": {} + }, + { + "type": "null" + } + ] } - ] - }, - "api_url": { - "type": "string", - "description": "External URL of the API server that frontend connects to.", - "default": "http://127.0.0.1" + }, + "additionalProperties": false }, - "openai_api_key": { - "type": "string", - "description": "OpenAI API key to use for Supabase AI in the Supabase Studio.", - "examples": [ - "env(OPENAI_API_KEY)" - ] - } - }, - "additionalProperties": false - }, - "workers": { - "anyOf": [ - { - "$ref": "#/$defs/Objects_27" - }, - { - "type": "null" - } - ] - }, - "experimental": { - "type": "object", - "properties": { - "orioledb_version": { - "type": "string", - "description": "Postgres storage engine version for OrioleDB." - }, - "s3_host": { - "type": "string", - "description": "S3 bucket URL.", - "examples": [ - ".s3-.amazonaws.com", - "env(S3_HOST)" - ] - }, - "s3_region": { - "type": "string", - "description": "S3 bucket region.", - "examples": [ - "us-east-1", - "env(S3_REGION)" - ] - }, - "s3_access_key": { - "type": "string", - "description": "S3 access key.", - "examples": [ - "env(S3_ACCESS_KEY)" - ] - }, - "s3_secret_key": { - "type": "string", - "description": "S3 secret key.", - "examples": [ - "env(S3_SECRET_KEY)" - ] - }, - "webhooks": { - "$ref": "#/$defs/Objects_28" - }, - "pgdelta": { - "$ref": "#/$defs/Objects_29" + "sms": { + "type": "object", + "properties": { + "enable_signup": { + "type": "boolean", + "description": "Allow/disallow new user signups via SMS to your project.", + "default": false + }, + "enable_confirmations": { + "type": "boolean", + "description": "If enabled, users need to confirm their phone number before signing in.", + "default": false + }, + "template": { + "type": "string", + "description": "The template to use for the SMS message.", + "default": "Your code is {{ .Code }}" + }, + "max_frequency": { + "type": "string", + "description": "Controls the minimum amount of time that must pass before sending another sms otp.", + "default": "5s" + }, + "twilio": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable Twilio provider for phone login.", + "default": false + }, + "account_sid": { + "type": "string", + "description": "The account SID for the Twilio API.", + "default": "" + }, + "message_service_sid": { + "type": "string", + "description": "The message service SID for the Twilio API.", + "default": "" + }, + "auth_token": { + "type": "string", + "description": "The auth token for the Twilio API.", + "examples": ["env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"] + } + }, + "additionalProperties": false + }, + "twilio_verify": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable Twilio Verify provider for phone verification.", + "default": false + }, + "account_sid": { + "type": "string", + "description": "The account SID for the Twilio API." + }, + "message_service_sid": { + "type": "string", + "description": "The message service SID for the Twilio API." + }, + "auth_token": { + "type": "string", + "description": "The auth token for the Twilio API." + } + }, + "additionalProperties": false + }, + "messagebird": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable MessageBird provider for phone login.", + "default": false + }, + "originator": { + "type": "string", + "description": "The originator of the SMS message." + }, + "access_key": { + "type": "string", + "description": "The access key for the MessageBird API." + } + }, + "additionalProperties": false + }, + "textlocal": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable Textlocal provider for phone login.", + "default": false + }, + "sender": { + "type": "string", + "description": "The sender of the SMS message." + }, + "api_key": { + "type": "string", + "description": "The API key for the Textlocal API." + } + }, + "additionalProperties": false + }, + "vonage": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable Vonage provider for phone login.", + "default": false + }, + "from": { + "type": "string", + "description": "The sender of the SMS message." + }, + "api_key": { + "type": "string", + "description": "The API key for the Vonage API." + }, + "api_secret": { + "type": "string", + "description": "The API secret for the Vonage API." + } + }, + "additionalProperties": false + }, + "test_otp": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Use pre-defined map of phone number to OTP for testing." + } + }, + "additionalProperties": false }, - "inspect": { - "$ref": "#/$defs/Objects_30" - } - }, - "additionalProperties": false - }, - "remotes": { - "anyOf": [ - { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "object", - "properties": { - "project_id": { - "type": "string", - "description": "Remote project reference.", - "default": "" - }, - "analytics": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable the local Logflare service.", - "default": true - }, - "port": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "backend": { - "type": "string", - "enum": [ - "postgres", - "bigquery" - ], - "description": "Configure one of the supported backends:\n\n- `postgres`\n- `bigquery`", - "default": "postgres" - }, - "vector_port": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "gcp_project_id": { - "type": "string", - "description": "GCP project ID." - }, - "gcp_project_number": { - "type": "string", - "description": "GCP project number." - }, - "gcp_jwt_path": { - "type": "string", - "description": "Path to the GCP JWT file." - } - }, - "additionalProperties": false - }, - "api": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable the local PostgREST service.", - "default": true - }, - "port": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "schemas": { - "$ref": "#/$defs/Arrays_" - }, - "extra_search_path": { - "$ref": "#/$defs/Arrays_1" - }, - "max_rows": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "auto_expose_new_tables": { - "type": "boolean", - "description": "Controls whether newly-created tables, views, sequences and functions in the `public` schema by `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`) without explicit GRANTs. When unset, new entities are auto-exposed, matching the cloud default. Set to `false` to revoke the default Data API privileges so new entities require explicit GRANTs, matching a cloud project with the \"Default privileges for new entities\" toggle turned off." - }, - "tls": { - "$ref": "#/$defs/Objects_" - }, - "external_url": { - "type": "string", - "description": "External URL for accessing the API server." - } - }, - "additionalProperties": false - }, - "auth": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable the local GoTrue service.", - "default": true - }, - "site_url": { - "type": "string", - "description": "The base URL of your website. Used as an allow-list for redirects and for constructing URLs used in emails.", - "default": "http://127.0.0.1:3000" - }, - "additional_redirect_urls": { - "$ref": "#/$defs/Arrays_2" - }, - "jwt_expiry": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "jwt_issuer": { - "type": "string", - "description": "JWT issuer URL." - }, - "signing_keys_path": { - "type": "string", - "description": "Path to the JWT signing keys file." - }, - "enable_refresh_token_rotation": { - "type": "boolean", - "description": "If disabled, the refresh token will never expire.", - "default": true - }, - "refresh_token_reuse_interval": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "enable_manual_linking": { - "type": "boolean", - "description": "Allow/disallow testing manual linking of accounts.", - "default": false - }, - "enable_signup": { - "type": "boolean", - "description": "Allow/disallow new user signups to your project.", - "default": true - }, - "enable_anonymous_sign_ins": { - "type": "boolean", - "description": "Allow/disallow anonymous sign-ins to your project.", - "default": false - }, - "minimum_password_length": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "password_requirements": { - "$ref": "#/$defs/Union_1" - }, - "publishable_key": { - "type": "string", - "description": "Publishable key override." - }, - "secret_key": { - "type": "string", - "description": "Secret key override." - }, - "jwt_secret": { - "type": "string", - "description": "JWT secret override." - }, - "anon_key": { - "type": "string", - "description": "Anon key override." - }, - "service_role_key": { - "type": "string", - "description": "Service role key override." - }, - "rate_limit": { - "$ref": "#/$defs/Objects_1" - }, - "captcha": { - "$ref": "#/$defs/Objects_2" - }, - "hook": { - "$ref": "#/$defs/Objects_3" - }, - "mfa": { - "$ref": "#/$defs/Objects_4" - }, - "sessions": { - "$ref": "#/$defs/Objects_5" - }, - "email": { - "$ref": "#/$defs/Objects_6" - }, - "sms": { - "$ref": "#/$defs/Objects_7" - }, - "external": { - "$ref": "#/$defs/Objects_8" - }, - "web3": { - "$ref": "#/$defs/Objects_9" - }, - "oauth_server": { - "$ref": "#/$defs/Objects_11" - }, - "third_party": { - "$ref": "#/$defs/Objects_12" - } - }, - "additionalProperties": false - }, - "db": { - "type": "object", - "properties": { - "port": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "shadow_port": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "health_timeout": { - "type": "string", - "description": "Maximum amount of time to wait for health check when starting the local database.", - "default": "2m" - }, - "major_version": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "pooler": { - "$ref": "#/$defs/Objects_13" - }, - "migrations": { - "$ref": "#/$defs/Objects_14" - }, - "seed": { - "$ref": "#/$defs/Objects_15" - }, - "settings": { - "$ref": "#/$defs/Objects_16" - }, - "network_restrictions": { - "$ref": "#/$defs/Objects_17" - }, - "ssl_enforcement": { - "$ref": "#/$defs/Objects_18" - }, - "vault": { - "$ref": "#/$defs/Objects_19" - } - }, - "additionalProperties": false - }, - "edge_runtime": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable the local Edge Runtime service.", - "default": true - }, - "policy": { - "type": "string", - "enum": [ - "oneshot", - "per_worker" - ], - "description": "Configure the supported request policy.", - "default": "per_worker" - }, - "inspector_port": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "deno_version": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "secrets": { - "$ref": "#/$defs/Objects_20" - } - }, - "additionalProperties": false - }, - "functions": { - "anyOf": [ - { - "$ref": "#/$defs/Objects_21" - }, - { - "type": "null" - } - ] - }, - "local_smtp": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable the local SMTP testing server.", - "default": true - }, - "port": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "smtp_port": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "pop3_port": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "admin_email": { - "type": "string", - "description": "Admin email address for test email sender metadata." - }, - "sender_name": { - "type": "string", - "description": "Sender name for test email sender metadata." - } - }, - "additionalProperties": false - }, - "realtime": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable the local Realtime service.", - "default": true - }, - "ip_version": { - "type": "string", - "enum": [ - "IPv4", - "IPv6" - ], - "description": "Bind realtime via either IPv4 or IPv6.", - "default": "IPv4" - }, - "max_header_length": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - } - }, - "additionalProperties": false - }, - "storage": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable the local Storage service.", - "default": true - }, - "file_size_limit": { - "anyOf": [ - { - "type": "string" - }, - { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - } - ] - }, - "image_transformation": { - "$ref": "#/$defs/Objects_22" - }, - "buckets": { - "$ref": "#/$defs/Objects_23" - }, - "s3_protocol": { - "$ref": "#/$defs/Objects_24" - }, - "analytics": { - "$ref": "#/$defs/Objects_25" - }, - "vector": { - "$ref": "#/$defs/Objects_26" - } - }, - "additionalProperties": false - }, - "studio": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable the local Supabase Studio dashboard.", - "default": true - }, - "port": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "api_url": { - "type": "string", - "description": "External URL of the API server that frontend connects to.", - "default": "http://127.0.0.1" - }, - "openai_api_key": { - "type": "string", - "description": "OpenAI API key to use for Supabase AI in the Supabase Studio.", - "examples": [ - "env(OPENAI_API_KEY)" - ] - } - }, - "additionalProperties": false - }, - "workers": { - "anyOf": [ - { - "$ref": "#/$defs/Objects_27" - }, - { - "type": "null" - } - ] - }, - "experimental": { - "type": "object", - "properties": { - "orioledb_version": { - "type": "string", - "description": "Postgres storage engine version for OrioleDB." - }, - "s3_host": { - "type": "string", - "description": "S3 bucket URL.", - "examples": [ - ".s3-.amazonaws.com", - "env(S3_HOST)" - ] - }, - "s3_region": { - "type": "string", - "description": "S3 bucket region.", - "examples": [ - "us-east-1", - "env(S3_REGION)" - ] - }, - "s3_access_key": { - "type": "string", - "description": "S3 access key.", - "examples": [ - "env(S3_ACCESS_KEY)" - ] - }, - "s3_secret_key": { - "type": "string", - "description": "S3 secret key.", - "examples": [ - "env(S3_SECRET_KEY)" - ] - }, - "webhooks": { - "$ref": "#/$defs/Objects_28" - }, - "pgdelta": { - "$ref": "#/$defs/Objects_29" - }, - "inspect": { - "$ref": "#/$defs/Objects_30" - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "description": "Remote branch-specific project configuration.", - "default": {} - }, - { - "type": "null" - } - ] - } - }, - "additionalProperties": false, - "$defs": { - "Union_": { - "type": "string", - "enum": [ - "Infinity", - "-Infinity", - "NaN" - ] - }, - "Arrays_": { - "type": "array", - "items": { - "type": "string", - "description": "Schemas to expose in your API. Tables, views and stored procedures in this schema will get API endpoints." - }, - "default": [ - "public", - "graphql_public" - ] - }, - "Arrays_1": { - "type": "array", - "items": { - "type": "string", - "description": "Extra schemas to add to the search_path of every request." - }, - "default": [ - "public", - "extensions" - ] - }, - "Objects_": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable HTTPS endpoints locally using a self-signed certificate.", - "default": false - }, - "cert_path": { - "type": "string", - "description": "Path to the self-signed certificate." - }, - "key_path": { - "type": "string", - "description": "Path to the self-signed certificate private key." - } - }, - "additionalProperties": false - }, - "Arrays_2": { - "type": "array", - "items": { - "type": "string", - "description": "A URL that auth providers are permitted to redirect to." - }, - "description": "A list of exact URLs that auth providers are permitted to redirect to post authentication.", - "default": [ - "https://127.0.0.1:3000" - ] - }, - "Union_1": { - "type": "string", - "enum": [ - "", - "letters_digits", - "lower_upper_letters_digits", - "lower_upper_letters_digits_symbols" - ], - "description": "Password character requirements.", - "default": "" - }, - "Objects_1": { - "type": "object", - "properties": { - "email_sent": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "sms_sent": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "anonymous_users": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "token_refresh": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "sign_in_sign_ups": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "token_verifications": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "web3": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - } - }, - "additionalProperties": false - }, - "Objects_2": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable CAPTCHA verification.", - "default": false - }, - "provider": { - "type": "string", - "enum": [ - "hcaptcha", - "turnstile" - ], - "description": "CAPTCHA provider to use." - }, - "secret": { - "type": "string", - "description": "Secret key for the CAPTCHA provider." - } - }, - "additionalProperties": false - }, - "Objects_3": { - "type": "object", - "properties": { - "mfa_verification_attempt": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable or disable the mfa verification hook.", - "default": false - }, - "uri": { - "type": "string", - "description": "The URI of the postgres function or HTTP endpoint to call." - }, - "secrets": { - "type": "string", - "description": "Secret value to pass to the function or endpoint." - } - }, - "additionalProperties": false - }, - "password_verification_attempt": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable or disable the password verification hook.", - "default": false - }, - "uri": { - "type": "string", - "description": "The URI of the postgres function or HTTP endpoint to call." - }, - "secrets": { - "type": "string", - "description": "Secret value to pass to the function or endpoint." - } - }, - "additionalProperties": false - }, - "custom_access_token": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable or disable the custom access token hook.", - "default": false - }, - "uri": { - "type": "string", - "description": "The URI of the postgres function or HTTP endpoint to call." - }, - "secrets": { - "type": "string", - "description": "Secret value to pass to the function or endpoint." - } - }, - "additionalProperties": false - }, - "send_sms": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable or disable the send sms hook.", - "default": false - }, - "uri": { - "type": "string", - "description": "The URI of the postgres function or HTTP endpoint to call." - }, - "secrets": { - "type": "string", - "description": "Secret value to pass to the function or endpoint." - } - }, - "additionalProperties": false - }, - "send_email": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable or disable the send email hook.", - "default": false - }, - "uri": { - "type": "string", - "description": "The URI of the postgres function or HTTP endpoint to call." - }, - "secrets": { - "type": "string", - "description": "Secret value to pass to the function or endpoint." - } - }, - "additionalProperties": false - }, - "before_user_created": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable or disable the before user created hook.", - "default": false - }, - "uri": { - "type": "string", - "description": "The URI of the postgres function or HTTP endpoint to call." - }, - "secrets": { - "type": "string", - "description": "Secret value to pass to the function or endpoint." - } - }, - "additionalProperties": false - } - }, - "additionalProperties": false - }, - "Objects_4": { - "type": "object", - "properties": { - "totp": { - "type": "object", - "properties": { - "enroll_enabled": { - "type": "boolean", - "description": "Allow/disallow TOTP enrollment for users.", - "default": false - }, - "verify_enabled": { - "type": "boolean", - "description": "Allow/disallow TOTP verification for users.", - "default": false - } - }, - "additionalProperties": false - }, - "phone": { - "type": "object", - "properties": { - "enroll_enabled": { - "type": "boolean", - "description": "Allow/disallow phone enrollment for users.", - "default": false - }, - "verify_enabled": { - "type": "boolean", - "description": "Allow/disallow phone verification for users.", - "default": false - }, - "otp_length": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "template": { - "type": "string", - "description": "The template to use for the phone message.", - "default": "Your code is {{ .Code }}" - }, - "max_frequency": { - "type": "string", - "description": "The maximum frequency of the phone messages.", - "default": "5s" - } - }, - "additionalProperties": false - }, - "web_authn": { - "type": "object", - "properties": { - "enroll_enabled": { - "type": "boolean", - "description": "Allow/disallow WebAuthn enrollment for users.", - "default": false - }, - "verify_enabled": { - "type": "boolean", - "description": "Allow/disallow WebAuthn verification for users.", - "default": false - } - }, - "additionalProperties": false - }, - "max_enrolled_factors": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - } - }, - "additionalProperties": false - }, - "Objects_5": { - "type": "object", - "properties": { - "timebox": { - "type": "string", - "description": "The timebox for the user session." - }, - "inactivity_timeout": { - "type": "string", - "description": "The inactivity timeout for the user session." - } - }, - "additionalProperties": false, - "default": {} - }, - "Objects_6": { - "type": "object", - "properties": { - "enable_signup": { - "type": "boolean", - "description": "Allow/disallow new user signups via email to your project.", - "default": true - }, - "double_confirm_changes": { - "type": "boolean", - "description": "If enabled, a user will be required to confirm any email change on both the old and new email addresses.", - "default": true - }, - "enable_confirmations": { - "type": "boolean", - "description": "If enabled, users need to confirm their email address before signing in.", - "default": false - }, - "secure_password_change": { - "type": "boolean", - "description": "If enabled, users will need to reauthenticate or have logged in recently to change their password.", - "default": false - }, - "max_frequency": { - "type": "string", - "description": "Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.", - "default": "1s" - }, - "otp_length": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "otp_expiry": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "smtp": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable SMTP for email delivery.", - "default": false - }, - "host": { - "type": "string", - "description": "Hostname or IP address of the SMTP server." - }, - "port": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "user": { - "type": "string", - "description": "Username for authenticating with the SMTP server." - }, - "pass": { - "type": "string", - "description": "Password for authenticating with the SMTP server." - }, - "admin_email": { - "type": "string", - "description": "Email used as the sender for emails sent from the application." - }, - "sender_name": { - "type": "string", - "description": "Display name used as the sender for emails sent from the application." - } - }, - "additionalProperties": false - }, - "template": { - "anyOf": [ - { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "object", - "properties": { - "subject": { - "type": "string", - "description": "Subject line for the email template.", - "default": "" - }, - "content_path": { - "type": "string", - "description": "Path to the HTML template.", - "default": "" - } - }, - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "description": "Custom email template configuration.", - "default": {} - }, - { - "type": "null" - } - ] - }, - "notification": { - "anyOf": [ - { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable the notification email.", - "default": false - }, - "subject": { - "type": "string", - "description": "Subject line for the notification email.", - "default": "" - }, - "content_path": { - "type": "string", - "description": "Path to the HTML notification template.", - "default": "" - } - }, - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "description": "Notification email configuration.", - "default": {} - }, - { - "type": "null" - } - ] - } - }, - "additionalProperties": false - }, - "Objects_7": { - "type": "object", - "properties": { - "enable_signup": { - "type": "boolean", - "description": "Allow/disallow new user signups via SMS to your project.", - "default": false - }, - "enable_confirmations": { - "type": "boolean", - "description": "If enabled, users need to confirm their phone number before signing in.", - "default": false - }, - "template": { - "type": "string", - "description": "The template to use for the SMS message.", - "default": "Your code is {{ .Code }}" - }, - "max_frequency": { - "type": "string", - "description": "Controls the minimum amount of time that must pass before sending another sms otp.", - "default": "5s" - }, - "twilio": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable/disable Twilio provider for phone login.", - "default": false - }, - "account_sid": { - "type": "string", - "description": "The account SID for the Twilio API.", - "default": "" - }, - "message_service_sid": { - "type": "string", - "description": "The message service SID for the Twilio API.", - "default": "" - }, - "auth_token": { - "type": "string", - "description": "The auth token for the Twilio API.", - "examples": [ - "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" - ] - } - }, - "additionalProperties": false - }, - "twilio_verify": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable/disable Twilio Verify provider for phone verification.", - "default": false - }, - "account_sid": { - "type": "string", - "description": "The account SID for the Twilio API." - }, - "message_service_sid": { - "type": "string", - "description": "The message service SID for the Twilio API." - }, - "auth_token": { - "type": "string", - "description": "The auth token for the Twilio API." - } - }, - "additionalProperties": false - }, - "messagebird": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable/disable MessageBird provider for phone login.", - "default": false - }, - "originator": { - "type": "string", - "description": "The originator of the SMS message." - }, - "access_key": { - "type": "string", - "description": "The access key for the MessageBird API." - } - }, - "additionalProperties": false - }, - "textlocal": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable/disable Textlocal provider for phone login.", - "default": false - }, - "sender": { - "type": "string", - "description": "The sender of the SMS message." - }, - "api_key": { - "type": "string", - "description": "The API key for the Textlocal API." - } - }, - "additionalProperties": false - }, - "vonage": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable/disable Vonage provider for phone login.", - "default": false - }, - "from": { - "type": "string", - "description": "The sender of the SMS message." - }, - "api_key": { - "type": "string", - "description": "The API key for the Vonage API." - }, - "api_secret": { - "type": "string", - "description": "The API secret for the Vonage API." - } - }, - "additionalProperties": false - }, - "test_otp": { - "type": "object", - "additionalProperties": { - "type": "string" - }, - "description": "Use pre-defined map of phone number to OTP for testing." - } - }, - "additionalProperties": false - }, - "Objects_8": { - "type": "object", - "properties": { - "apple": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Use the Apple OAuth provider.", - "default": false - }, - "client_id": { - "type": "string", - "description": "Client ID for the Apple OAuth provider.", - "default": "" - }, - "secret": { - "type": "string", - "description": "Client secret for the Apple OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", - "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" - ] - }, - "url": { - "type": "string", - "description": "The base URL used for constructing the URLs to request authorization and access tokens.", - "default": "" - }, - "redirect_uri": { - "type": "string", - "description": "The URI the Apple OAuth2 provider will redirect to with the code and state values.", - "default": "" - }, - "skip_nonce_check": { - "type": "boolean", - "description": "If true, the nonce check will be skipped.", - "default": false - }, - "email_optional": { - "type": "boolean", - "description": "If true, authentication succeeds when the provider does not return an email address.", - "default": false - } - }, - "additionalProperties": false - }, - "azure": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Use the Azure OAuth provider.", - "default": false - }, - "client_id": { - "type": "string", - "description": "Client ID for the Azure OAuth provider.", - "default": "" - }, - "secret": { - "type": "string", - "description": "Client secret for the Azure OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", - "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_AZURE_SECRET)" - ] - }, - "url": { - "type": "string", - "description": "The base URL used for constructing the URLs to request authorization and access tokens.", - "default": "" - }, - "redirect_uri": { - "type": "string", - "description": "The URI the Azure OAuth2 provider will redirect to with the code and state values.", - "default": "" - }, - "skip_nonce_check": { - "type": "boolean", - "description": "If true, the nonce check will be skipped.", - "default": false - }, - "email_optional": { - "type": "boolean", - "description": "If true, authentication succeeds when the provider does not return an email address.", - "default": false - } - }, - "additionalProperties": false - }, - "bitbucket": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Use the Bitbucket OAuth provider.", - "default": false - }, - "client_id": { - "type": "string", - "description": "Client ID for the Bitbucket OAuth provider.", - "default": "" - }, - "secret": { - "type": "string", - "description": "Client secret for the Bitbucket OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", - "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_BITBUCKET_SECRET)" - ] - }, - "url": { - "type": "string", - "description": "The base URL used for constructing the URLs to request authorization and access tokens.", - "default": "" - }, - "redirect_uri": { - "type": "string", - "description": "The URI the Bitbucket OAuth2 provider will redirect to with the code and state values.", - "default": "" - }, - "skip_nonce_check": { - "type": "boolean", - "description": "If true, the nonce check will be skipped.", - "default": false - }, - "email_optional": { - "type": "boolean", - "description": "If true, authentication succeeds when the provider does not return an email address.", - "default": false - } - }, - "additionalProperties": false - }, - "discord": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Use the Discord OAuth provider.", - "default": false - }, - "client_id": { - "type": "string", - "description": "Client ID for the Discord OAuth provider.", - "default": "" - }, - "secret": { - "type": "string", - "description": "Client secret for the Discord OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", - "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_DISCORD_SECRET)" - ] - }, - "url": { - "type": "string", - "description": "The base URL used for constructing the URLs to request authorization and access tokens.", - "default": "" - }, - "redirect_uri": { - "type": "string", - "description": "The URI the Discord OAuth2 provider will redirect to with the code and state values.", - "default": "" - }, - "skip_nonce_check": { - "type": "boolean", - "description": "If true, the nonce check will be skipped.", - "default": false - }, - "email_optional": { - "type": "boolean", - "description": "If true, authentication succeeds when the provider does not return an email address.", - "default": false - } - }, - "additionalProperties": false - }, - "facebook": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Use the Facebook OAuth provider.", - "default": false - }, - "client_id": { - "type": "string", - "description": "Client ID for the Facebook OAuth provider.", - "default": "" - }, - "secret": { - "type": "string", - "description": "Client secret for the Facebook OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", - "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_FACEBOOK_SECRET)" - ] - }, - "url": { - "type": "string", - "description": "The base URL used for constructing the URLs to request authorization and access tokens.", - "default": "" - }, - "redirect_uri": { - "type": "string", - "description": "The URI the Facebook OAuth2 provider will redirect to with the code and state values.", - "default": "" - }, - "skip_nonce_check": { - "type": "boolean", - "description": "If true, the nonce check will be skipped.", - "default": false - }, - "email_optional": { - "type": "boolean", - "description": "If true, authentication succeeds when the provider does not return an email address.", - "default": false - } - }, - "additionalProperties": false - }, - "github": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Use the GitHub OAuth provider.", - "default": false - }, - "client_id": { - "type": "string", - "description": "Client ID for the GitHub OAuth provider.", - "default": "" - }, - "secret": { - "type": "string", - "description": "Client secret for the GitHub OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", - "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET)" - ] - }, - "url": { - "type": "string", - "description": "The base URL used for constructing the URLs to request authorization and access tokens.", - "default": "" - }, - "redirect_uri": { - "type": "string", - "description": "The URI the GitHub OAuth2 provider will redirect to with the code and state values.", - "default": "" - }, - "skip_nonce_check": { - "type": "boolean", - "description": "If true, the nonce check will be skipped.", - "default": false - }, - "email_optional": { - "type": "boolean", - "description": "If true, authentication succeeds when the provider does not return an email address.", - "default": false - } - }, - "additionalProperties": false - }, - "gitlab": { + "external": { "type": "object", "properties": { - "enabled": { - "type": "boolean", - "description": "Use the GitLab OAuth provider.", - "default": false + "apple": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Apple OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Apple OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Apple OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Apple OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false }, - "client_id": { - "type": "string", - "description": "Client ID for the GitLab OAuth provider.", - "default": "" + "azure": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Azure OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Azure OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Azure OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_AZURE_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Azure OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false }, - "secret": { - "type": "string", - "description": "Client secret for the GitLab OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", - "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_GITLAB_SECRET)" - ] + "bitbucket": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Bitbucket OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Bitbucket OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Bitbucket OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_BITBUCKET_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Bitbucket OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false }, - "url": { - "type": "string", - "description": "The base URL used for constructing the URLs to request authorization and access tokens.", - "default": "https://gitlab.com" + "discord": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Discord OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Discord OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Discord OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_DISCORD_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Discord OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false }, - "redirect_uri": { - "type": "string", - "description": "The URI the GitLab OAuth2 provider will redirect to with the code and state values.", - "default": "" + "facebook": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Facebook OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Facebook OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Facebook OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_FACEBOOK_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Facebook OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false }, - "skip_nonce_check": { - "type": "boolean", - "description": "If true, the nonce check will be skipped.", - "default": false + "github": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the GitHub OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the GitHub OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the GitHub OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the GitHub OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false }, - "email_optional": { - "type": "boolean", - "description": "If true, authentication succeeds when the provider does not return an email address.", - "default": false - } - }, - "additionalProperties": false - }, - "google": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Use the Google OAuth provider.", - "default": false + "gitlab": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the GitLab OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the GitLab OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the GitLab OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_GITLAB_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "https://gitlab.com" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the GitLab OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false }, - "client_id": { - "type": "string", - "description": "Client ID for the Google OAuth provider.", - "default": "" + "google": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Google OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Google OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Google OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_GOOGLE_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Google OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false }, - "secret": { - "type": "string", - "description": "Client secret for the Google OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", - "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_GOOGLE_SECRET)" - ] + "kakao": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Kakao OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Kakao OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Kakao OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_KAKAO_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Kakao OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false }, - "url": { - "type": "string", - "description": "The base URL used for constructing the URLs to request authorization and access tokens.", - "default": "" + "keycloak": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Keycloak OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Keycloak OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Keycloak OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_KEYCLOAK_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "", + "examples": ["https://keycloak.example.com/realms/myrealm"] + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Keycloak OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false }, - "redirect_uri": { - "type": "string", - "description": "The URI the Google OAuth2 provider will redirect to with the code and state values.", - "default": "" + "linkedin_oidc": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the LinkedIn OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the LinkedIn OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the LinkedIn OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_LINKEDIN_OIDC_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the LinkedIn OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false }, - "skip_nonce_check": { - "type": "boolean", - "description": "If true, the nonce check will be skipped.", - "default": false + "notion": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Notion OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Notion OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Notion OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_NOTION_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Notion OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false }, - "email_optional": { - "type": "boolean", - "description": "If true, authentication succeeds when the provider does not return an email address.", - "default": false - } - }, - "additionalProperties": false - }, - "kakao": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Use the Kakao OAuth provider.", - "default": false + "twitch": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Twitch OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Twitch OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Twitch OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_TWITCH_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Twitch OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false }, - "client_id": { - "type": "string", - "description": "Client ID for the Kakao OAuth provider.", - "default": "" + "twitter": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Twitter OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Twitter OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Twitter OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_TWITTER_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Twitter OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false }, - "secret": { - "type": "string", - "description": "Client secret for the Kakao OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", - "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_KAKAO_SECRET)" - ] + "x": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the X OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the X OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the X OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_X_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the X OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false }, - "url": { - "type": "string", - "description": "The base URL used for constructing the URLs to request authorization and access tokens.", - "default": "" + "slack_oidc": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Slack OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Slack OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Slack OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_SLACK_OIDC_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Slack OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false }, - "redirect_uri": { - "type": "string", - "description": "The URI the Kakao OAuth2 provider will redirect to with the code and state values.", - "default": "" + "spotify": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Spotify OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Spotify OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Spotify OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_SPOTIFY_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Spotify OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false }, - "skip_nonce_check": { - "type": "boolean", - "description": "If true, the nonce check will be skipped.", - "default": false + "workos": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the WorkOS OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the WorkOS OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the WorkOS OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_WORKOS_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the WorkOS OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false }, - "email_optional": { - "type": "boolean", - "description": "If true, authentication succeeds when the provider does not return an email address.", - "default": false + "zoom": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Zoom OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Zoom OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Zoom OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_ZOOM_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Zoom OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false } }, "additionalProperties": false }, - "keycloak": { + "web3": { "type": "object", "properties": { - "enabled": { - "type": "boolean", - "description": "Use the Keycloak OAuth provider.", - "default": false - }, - "client_id": { - "type": "string", - "description": "Client ID for the Keycloak OAuth provider.", - "default": "" - }, - "secret": { - "type": "string", - "description": "Client secret for the Keycloak OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", - "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_KEYCLOAK_SECRET)" - ] - }, - "url": { - "type": "string", - "description": "The base URL used for constructing the URLs to request authorization and access tokens.", - "default": "", - "examples": [ - "https://keycloak.example.com/realms/myrealm" - ] - }, - "redirect_uri": { - "type": "string", - "description": "The URI the Keycloak OAuth2 provider will redirect to with the code and state values.", - "default": "" - }, - "skip_nonce_check": { - "type": "boolean", - "description": "If true, the nonce check will be skipped.", - "default": false + "solana": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this Web3 provider.", + "default": false + } + }, + "additionalProperties": false }, - "email_optional": { - "type": "boolean", - "description": "If true, authentication succeeds when the provider does not return an email address.", - "default": false + "ethereum": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this Web3 provider.", + "default": false + } + }, + "additionalProperties": false } }, "additionalProperties": false }, - "linkedin_oidc": { + "oauth_server": { "type": "object", "properties": { "enabled": { "type": "boolean", - "description": "Use the LinkedIn OAuth provider.", + "description": "Enable OAuth server functionality.", "default": false }, - "client_id": { - "type": "string", - "description": "Client ID for the LinkedIn OAuth provider.", - "default": "" - }, - "secret": { - "type": "string", - "description": "Client secret for the LinkedIn OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", - "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_LINKEDIN_OIDC_SECRET)" - ] - }, - "url": { - "type": "string", - "description": "The base URL used for constructing the URLs to request authorization and access tokens.", - "default": "" - }, - "redirect_uri": { + "authorization_url_path": { "type": "string", - "description": "The URI the LinkedIn OAuth2 provider will redirect to with the code and state values.", - "default": "" - }, - "skip_nonce_check": { - "type": "boolean", - "description": "If true, the nonce check will be skipped.", - "default": false + "description": "Path for OAuth consent flow UI.", + "default": "/oauth/consent" }, - "email_optional": { + "allow_dynamic_registration": { "type": "boolean", - "description": "If true, authentication succeeds when the provider does not return an email address.", + "description": "Allow dynamic client registration.", "default": false } }, "additionalProperties": false }, - "notion": { + "third_party": { "type": "object", "properties": { - "enabled": { - "type": "boolean", - "description": "Use the Notion OAuth provider.", - "default": false - }, - "client_id": { - "type": "string", - "description": "Client ID for the Notion OAuth provider.", - "default": "" - }, - "secret": { - "type": "string", - "description": "Client secret for the Notion OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", - "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_NOTION_SECRET)" - ] + "firebase": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this third-party auth provider.", + "default": false + }, + "project_id": { + "type": "string", + "description": "Firebase project ID." + } + }, + "additionalProperties": false }, - "url": { - "type": "string", - "description": "The base URL used for constructing the URLs to request authorization and access tokens.", - "default": "" + "auth0": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this third-party auth provider.", + "default": false + }, + "tenant": { + "type": "string", + "description": "Auth0 tenant." + }, + "tenant_region": { + "type": "string", + "description": "Auth0 tenant region." + } + }, + "additionalProperties": false }, - "redirect_uri": { - "type": "string", - "description": "The URI the Notion OAuth2 provider will redirect to with the code and state values.", - "default": "" + "aws_cognito": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this third-party auth provider.", + "default": false + }, + "user_pool_id": { + "type": "string", + "description": "AWS Cognito user pool ID." + }, + "user_pool_region": { + "type": "string", + "description": "AWS Cognito user pool region." + } + }, + "additionalProperties": false }, - "skip_nonce_check": { - "type": "boolean", - "description": "If true, the nonce check will be skipped.", - "default": false + "clerk": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this third-party auth provider.", + "default": false + }, + "domain": { + "type": "string", + "description": "Clerk domain." + } + }, + "additionalProperties": false }, - "email_optional": { - "type": "boolean", - "description": "If true, authentication succeeds when the provider does not return an email address.", - "default": false + "workos": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this third-party auth provider.", + "default": false + }, + "issuer_url": { + "type": "string", + "description": "WorkOS issuer URL." + } + }, + "additionalProperties": false } }, "additionalProperties": false + } + }, + "additionalProperties": false + }, + "db": { + "type": "object", + "properties": { + "port": { + "type": "number", + "description": "Port to use for the local database URL.", + "default": 54322 + }, + "shadow_port": { + "type": "number", + "description": "Port used by db diff command to initialize the shadow database.", + "default": 54320 + }, + "health_timeout": { + "type": "string", + "description": "Maximum amount of time to wait for health check when starting the local database.", + "default": "2m" + }, + "major_version": { + "type": "number", + "description": "The database major version to use. This has to be the same as your remote database's.", + "default": 17 }, - "twitch": { + "pooler": { "type": "object", "properties": { "enabled": { "type": "boolean", - "description": "Use the Twitch OAuth provider.", + "description": "Enable the local PgBouncer service.", "default": false }, - "client_id": { - "type": "string", - "description": "Client ID for the Twitch OAuth provider.", - "default": "" - }, - "secret": { - "type": "string", - "description": "Client secret for the Twitch OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", - "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_TWITCH_SECRET)" - ] - }, - "url": { - "type": "string", - "description": "The base URL used for constructing the URLs to request authorization and access tokens.", - "default": "" + "port": { + "type": "number", + "description": "Port to use for the local connection pooler.", + "default": 54329 }, - "redirect_uri": { + "pool_mode": { "type": "string", - "description": "The URI the Twitch OAuth2 provider will redirect to with the code and state values.", - "default": "" - }, - "skip_nonce_check": { - "type": "boolean", - "description": "If true, the nonce check will be skipped.", - "default": false - }, - "email_optional": { - "type": "boolean", - "description": "If true, authentication succeeds when the provider does not return an email address.", - "default": false + "enum": ["transaction", "session"], + "description": "Specifies when a server connection can be reused by other clients.", + "default": "transaction" + }, + "default_pool_size": { + "type": "number", + "description": "How many server connections to allow per user/database pair.", + "default": 20 + }, + "max_client_conn": { + "type": "number", + "description": "Maximum number of client connections allowed.", + "default": 100 } }, "additionalProperties": false }, - "twitter": { + "migrations": { "type": "object", "properties": { "enabled": { "type": "boolean", - "description": "Use the Twitter OAuth provider.", - "default": false - }, - "client_id": { - "type": "string", - "description": "Client ID for the Twitter OAuth provider.", - "default": "" - }, - "secret": { - "type": "string", - "description": "Client secret for the Twitter OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", - "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_TWITTER_SECRET)" - ] - }, - "url": { - "type": "string", - "description": "The base URL used for constructing the URLs to request authorization and access tokens.", - "default": "" - }, - "redirect_uri": { - "type": "string", - "description": "The URI the Twitter OAuth2 provider will redirect to with the code and state values.", - "default": "" - }, - "skip_nonce_check": { - "type": "boolean", - "description": "If true, the nonce check will be skipped.", - "default": false + "description": "If disabled, migrations will be skipped during a db push or reset.", + "default": true }, - "email_optional": { - "type": "boolean", - "description": "If true, authentication succeeds when the provider does not return an email address.", - "default": false + "schema_paths": { + "type": "array", + "items": { + "type": "string", + "description": "Schema file path, directory, or glob relative to the supabase directory." + }, + "description": "Ordered list of schema files, directories, or glob patterns that describe your database.", + "default": [] } }, "additionalProperties": false }, - "x": { + "seed": { "type": "object", "properties": { "enabled": { "type": "boolean", - "description": "Use the X OAuth provider.", - "default": false - }, - "client_id": { - "type": "string", - "description": "Client ID for the X OAuth provider.", - "default": "" - }, - "secret": { - "type": "string", - "description": "Client secret for the X OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", - "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_X_SECRET)" - ] + "description": "Enable seeding the database with SQL files.", + "default": true }, - "url": { - "type": "string", - "description": "The base URL used for constructing the URLs to request authorization and access tokens.", - "default": "" - }, - "redirect_uri": { - "type": "string", - "description": "The URI the X OAuth2 provider will redirect to with the code and state values.", - "default": "" - }, - "skip_nonce_check": { - "type": "boolean", - "description": "If true, the nonce check will be skipped.", - "default": false - }, - "email_optional": { - "type": "boolean", - "description": "If true, authentication succeeds when the provider does not return an email address.", - "default": false + "sql_paths": { + "type": "array", + "items": { + "type": "string", + "description": "Path to a SQL file used to seed the database." + }, + "description": "Ordered list of seed files to load during db reset.", + "default": ["./seed.sql"] } }, "additionalProperties": false }, - "slack_oidc": { + "settings": { "type": "object", "properties": { - "enabled": { - "type": "boolean", - "description": "Use the Slack OAuth provider.", - "default": false + "effective_cache_size": { + "type": "string" }, - "client_id": { - "type": "string", - "description": "Client ID for the Slack OAuth provider.", - "default": "" + "logical_decoding_work_mem": { + "type": "string" }, - "secret": { - "type": "string", - "description": "Client secret for the Slack OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", - "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_SLACK_OIDC_SECRET)" - ] + "maintenance_work_mem": { + "type": "string" }, - "url": { - "type": "string", - "description": "The base URL used for constructing the URLs to request authorization and access tokens.", - "default": "" + "max_connections": { + "type": "number" }, - "redirect_uri": { - "type": "string", - "description": "The URI the Slack OAuth2 provider will redirect to with the code and state values.", - "default": "" + "max_locks_per_transaction": { + "type": "number" }, - "skip_nonce_check": { - "type": "boolean", - "description": "If true, the nonce check will be skipped.", - "default": false + "max_parallel_maintenance_workers": { + "type": "number" }, - "email_optional": { - "type": "boolean", - "description": "If true, authentication succeeds when the provider does not return an email address.", - "default": false - } - }, - "additionalProperties": false - }, - "spotify": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Use the Spotify OAuth provider.", - "default": false + "max_parallel_workers": { + "type": "number" }, - "client_id": { - "type": "string", - "description": "Client ID for the Spotify OAuth provider.", - "default": "" + "max_parallel_workers_per_gather": { + "type": "number" }, - "secret": { - "type": "string", - "description": "Client secret for the Spotify OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", - "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_SPOTIFY_SECRET)" - ] + "max_replication_slots": { + "type": "number" }, - "url": { - "type": "string", - "description": "The base URL used for constructing the URLs to request authorization and access tokens.", - "default": "" + "max_slot_wal_keep_size": { + "type": "string" + }, + "max_standby_archive_delay": { + "type": "string" + }, + "max_standby_streaming_delay": { + "type": "string" + }, + "max_wal_size": { + "type": "string" + }, + "max_wal_senders": { + "type": "number" + }, + "max_worker_processes": { + "type": "number" }, - "redirect_uri": { + "session_replication_role": { "type": "string", - "description": "The URI the Spotify OAuth2 provider will redirect to with the code and state values.", - "default": "" + "enum": ["origin", "replica", "local"], + "description": "Session replication role." }, - "skip_nonce_check": { - "type": "boolean", - "description": "If true, the nonce check will be skipped.", - "default": false + "shared_buffers": { + "type": "string" }, - "email_optional": { - "type": "boolean", - "description": "If true, authentication succeeds when the provider does not return an email address.", - "default": false + "statement_timeout": { + "type": "string" + }, + "track_activity_query_size": { + "type": "string" + }, + "track_commit_timestamp": { + "type": "boolean" + }, + "wal_keep_size": { + "type": "string" + }, + "wal_sender_timeout": { + "type": "string" + }, + "work_mem": { + "type": "string" } }, "additionalProperties": false }, - "workos": { + "network_restrictions": { "type": "object", "properties": { "enabled": { "type": "boolean", - "description": "Use the WorkOS OAuth provider.", + "description": "Enable management of network restrictions.", "default": false }, - "client_id": { - "type": "string", - "description": "Client ID for the WorkOS OAuth provider.", - "default": "" - }, - "secret": { - "type": "string", - "description": "Client secret for the WorkOS OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", - "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_WORKOS_SECRET)" - ] - }, - "url": { - "type": "string", - "description": "The base URL used for constructing the URLs to request authorization and access tokens.", - "default": "" - }, - "redirect_uri": { - "type": "string", - "description": "The URI the WorkOS OAuth2 provider will redirect to with the code and state values.", - "default": "" - }, - "skip_nonce_check": { - "type": "boolean", - "description": "If true, the nonce check will be skipped.", - "default": false + "allowed_cidrs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Allowed IPv4 CIDR blocks.", + "default": ["0.0.0.0/0"] }, - "email_optional": { - "type": "boolean", - "description": "If true, authentication succeeds when the provider does not return an email address.", - "default": false + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Allowed IPv6 CIDR blocks.", + "default": ["::/0"] } }, "additionalProperties": false }, - "zoom": { + "ssl_enforcement": { "type": "object", "properties": { "enabled": { "type": "boolean", - "description": "Use the Zoom OAuth provider.", - "default": false - }, - "client_id": { - "type": "string", - "description": "Client ID for the Zoom OAuth provider.", - "default": "" - }, - "secret": { - "type": "string", - "description": "Client secret for the Zoom OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", - "examples": [ - "env(SUPABASE_AUTH_EXTERNAL_ZOOM_SECRET)" - ] - }, - "url": { - "type": "string", - "description": "The base URL used for constructing the URLs to request authorization and access tokens.", - "default": "" - }, - "redirect_uri": { - "type": "string", - "description": "The URI the Zoom OAuth2 provider will redirect to with the code and state values.", - "default": "" - }, - "skip_nonce_check": { - "type": "boolean", - "description": "If true, the nonce check will be skipped.", - "default": false - }, - "email_optional": { - "type": "boolean", - "description": "If true, authentication succeeds when the provider does not return an email address.", + "description": "Reject non-secure connections to the database.", "default": false } }, "additionalProperties": false + }, + "vault": { + "type": "object", + "additionalProperties": { + "type": "string", + "description": "Vault secret value." + }, + "description": "Vault secrets." } }, "additionalProperties": false }, - "Objects_10": { + "edge_runtime": { "type": "object", "properties": { "enabled": { "type": "boolean", - "description": "Enable this Web3 provider.", - "default": false + "description": "Enable the local Edge Runtime service.", + "default": true + }, + "policy": { + "type": "string", + "enum": ["oneshot", "per_worker"], + "description": "Configure the supported request policy.", + "default": "per_worker" + }, + "inspector_port": { + "type": "number", + "description": "Port to run the Edge Functions inspector on.", + "default": 8083 + }, + "deno_version": { + "type": "number", + "description": "The Deno major version to use.", + "default": 2 + }, + "secrets": { + "type": "object", + "additionalProperties": { + "type": "string", + "description": "Secret value exposed to the edge runtime." + }, + "description": "Secrets exposed to the edge runtime." } }, "additionalProperties": false }, - "Objects_9": { + "functions": { + "anyOf": [ + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9_-]+$": { + "anyOf": [ + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Controls whether a function is deployed or served.", + "default": true + }, + "verify_jwt": { + "type": "boolean", + "description": "By default, deployed or locally served functions reject requests without a valid JWT.", + "default": true + }, + "import_map": { + "type": "string", + "description": "Import map file to use for the Function.", + "default": "" + }, + "entrypoint": { + "type": "string", + "description": "Entrypoint path to the Function. Defaults to \"functions/slug/index.ts\".", + "default": "" + }, + "static_files": { + "type": "array", + "items": { + "type": "string", + "description": "Static file glob for the function." + }, + "description": "Static files to bundle with the function.", + "default": [] + }, + "env": { + "type": "object", + "patternProperties": { + "^[A-Z_][A-Z0-9_]*$": { + "type": "string", + "pattern": "^env\\((.*)\\)$", + "description": "Reference to a project environment variable available to the Function." + } + }, + "description": "Environment variables from the project environment that this Function can access.", + "default": {} + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "description": "Function-specific configuration keyed by function slug.", + "default": {} + }, + { + "type": "null" + } + ] + }, + "local_smtp": { "type": "object", "properties": { - "solana": { - "$ref": "#/$defs/Objects_10" + "enabled": { + "type": "boolean", + "description": "Enable the local SMTP testing server.", + "default": true + }, + "port": { + "type": "number", + "description": "Port to use for the email testing server web interface.\n\nEmails sent with the local dev setup are monitored and available from the web interface.", + "default": 54324 + }, + "smtp_port": { + "type": "number", + "description": "Optional SMTP port to expose for local testing." + }, + "pop3_port": { + "type": "number", + "description": "Optional POP3 port to expose for local testing." + }, + "admin_email": { + "type": "string", + "description": "Admin email address for test email sender metadata." }, - "ethereum": { - "$ref": "#/$defs/Objects_10" + "sender_name": { + "type": "string", + "description": "Sender name for test email sender metadata." } }, "additionalProperties": false }, - "Objects_11": { + "realtime": { "type": "object", "properties": { "enabled": { "type": "boolean", - "description": "Enable OAuth server functionality.", - "default": false + "description": "Enable the local Realtime service.", + "default": true }, - "authorization_url_path": { + "ip_version": { "type": "string", - "description": "Path for OAuth consent flow UI.", - "default": "/oauth/consent" - }, - "allow_dynamic_registration": { - "type": "boolean", - "description": "Allow dynamic client registration.", - "default": false + "enum": ["IPv4", "IPv6"], + "description": "Bind realtime via either IPv4 or IPv6.", + "default": "IPv4" + }, + "max_header_length": { + "type": "number", + "description": "Maximum length of the HTTP header.", + "default": 4096 } }, "additionalProperties": false }, - "Objects_12": { + "storage": { "type": "object", "properties": { - "firebase": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable this third-party auth provider.", - "default": false + "enabled": { + "type": "boolean", + "description": "Enable the local Storage service.", + "default": true + }, + "file_size_limit": { + "anyOf": [ + { + "type": "string" }, - "project_id": { - "type": "string", - "description": "Firebase project ID." + { + "type": "number" } - }, - "additionalProperties": false + ] }, - "auth0": { + "image_transformation": { "type": "object", "properties": { "enabled": { "type": "boolean", - "description": "Enable this third-party auth provider.", + "description": "Enable image transformation.", "default": false - }, - "tenant": { - "type": "string", - "description": "Auth0 tenant." - }, - "tenant_region": { - "type": "string", - "description": "Auth0 tenant region." } }, "additionalProperties": false }, - "aws_cognito": { + "buckets": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "public": { + "type": "boolean", + "description": "Enable public access to the bucket.", + "default": false + }, + "file_size_limit": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "allowed_mime_types": { + "type": "array", + "items": { + "type": "string", + "description": "A MIME type allowed for the bucket." + }, + "description": "The list of allowed MIME types for the bucket.", + "default": [] + }, + "objects_path": { + "type": "string", + "description": "The path to the objects in the bucket.", + "default": "" + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "description": "Storage buckets configuration." + }, + "s3_protocol": { "type": "object", "properties": { "enabled": { "type": "boolean", - "description": "Enable this third-party auth provider.", - "default": false - }, - "user_pool_id": { - "type": "string", - "description": "AWS Cognito user pool ID." - }, - "user_pool_region": { - "type": "string", - "description": "AWS Cognito user pool region." + "description": "Allow connections via S3 compatible clients.", + "default": true } }, "additionalProperties": false }, - "clerk": { + "analytics": { "type": "object", "properties": { "enabled": { "type": "boolean", - "description": "Enable this third-party auth provider.", + "description": "Enable analytics buckets.", "default": false }, - "domain": { - "type": "string", - "description": "Clerk domain." + "max_namespaces": { + "type": "number", + "description": "Maximum number of analytics namespaces.", + "default": 5 + }, + "max_tables": { + "type": "number", + "description": "Maximum number of analytics tables.", + "default": 10 + }, + "max_catalogs": { + "type": "number", + "description": "Maximum number of analytics catalogs.", + "default": 2 + }, + "buckets": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + }, + { + "type": "null" + } + ] + }, + "description": "Analytics bucket configuration.", + "default": {} + }, + { + "type": "null" + } + ] } }, "additionalProperties": false }, - "workos": { + "vector": { "type": "object", "properties": { "enabled": { "type": "boolean", - "description": "Enable this third-party auth provider.", - "default": false + "description": "Enable vector buckets.", + "default": true }, - "issuer_url": { - "type": "string", - "description": "WorkOS issuer URL." + "max_buckets": { + "type": "number", + "description": "Maximum number of vector buckets.", + "default": 10 + }, + "max_indexes": { + "type": "number", + "description": "Maximum number of vector indexes.", + "default": 5 + }, + "buckets": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + }, + { + "type": "null" + } + ] + }, + "description": "Vector bucket configuration.", + "default": {} + }, + { + "type": "null" + } + ] } }, "additionalProperties": false @@ -2823,631 +2270,2625 @@ }, "additionalProperties": false }, - "Objects_13": { + "studio": { "type": "object", "properties": { "enabled": { "type": "boolean", - "description": "Enable the local PgBouncer service.", - "default": false + "description": "Enable the local Supabase Studio dashboard.", + "default": true }, "port": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] + "type": "number", + "description": "Port to use for Supabase Studio.", + "default": 54323 }, - "pool_mode": { + "api_url": { "type": "string", - "enum": [ - "transaction", - "session" - ], - "description": "Specifies when a server connection can be reused by other clients.", - "default": "transaction" - }, - "default_pool_size": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "max_client_conn": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - } - }, - "additionalProperties": false - }, - "Objects_14": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "If disabled, migrations will be skipped during a db push or reset.", - "default": true - }, - "schema_paths": { - "type": "array", - "items": { - "type": "string", - "description": "Schema file path, directory, or glob relative to the supabase directory." - }, - "description": "Ordered list of schema files, directories, or glob patterns that describe your database.", - "default": [] - } - }, - "additionalProperties": false - }, - "Objects_15": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable seeding the database with SQL files.", - "default": true + "description": "External URL of the API server that frontend connects to.", + "default": "http://127.0.0.1" }, - "sql_paths": { - "type": "array", - "items": { - "type": "string", - "description": "Path to a SQL file used to seed the database." - }, - "description": "Ordered list of seed files to load during db reset.", - "default": [ - "./seed.sql" - ] + "openai_api_key": { + "type": "string", + "description": "OpenAI API key to use for Supabase AI in the Supabase Studio.", + "examples": ["env(OPENAI_API_KEY)"] } }, "additionalProperties": false }, - "Union_2": { + "workers": { "anyOf": [ { - "type": "number" + "type": "object", + "patternProperties": { + "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$": { + "type": "object", + "properties": { + "runtime": { + "type": "string", + "description": "Runtime the worker is built on: `dockerfile` to build the directory's own\nDockerfile, or one of the catalog runtimes (`node`, `deno`). Guessed from\nmarker files when unset.", + "examples": ["node"] + }, + "size": { + "type": "string", + "description": "Instance size, denominated by memory. Each size implies its own vCPU count,\nso it is the one dial rather than two.", + "examples": ["2gb"] + }, + "instances": { + "type": "integer", + "minimum": 0, + "description": "Number of instances to run. Every deploy sends a complete spec, so a count\nrecorded here is what keeps a scaled worker scaled; `--instances` overrides\nit for one deploy. Defaults to 1.", + "examples": [3] + }, + "source": { + "type": "string", + "description": "Directory holding the worker's code, relative to the project root, when it\ndoes not live at `supabase/workers//`.", + "examples": ["packages/api"] + } + }, + "additionalProperties": false + } + }, + "description": "Worker-specific configuration keyed by worker name.", + "default": {} }, { - "$ref": "#/$defs/Union_" + "type": "null" } ] }, - "Objects_16": { + "experimental": { "type": "object", "properties": { - "effective_cache_size": { - "type": "string" - }, - "logical_decoding_work_mem": { - "type": "string" - }, - "maintenance_work_mem": { - "type": "string" - }, - "max_connections": { - "$ref": "#/$defs/Union_2" - }, - "max_locks_per_transaction": { - "$ref": "#/$defs/Union_2" - }, - "max_parallel_maintenance_workers": { - "$ref": "#/$defs/Union_2" - }, - "max_parallel_workers": { - "$ref": "#/$defs/Union_2" - }, - "max_parallel_workers_per_gather": { - "$ref": "#/$defs/Union_2" - }, - "max_replication_slots": { - "$ref": "#/$defs/Union_2" - }, - "max_slot_wal_keep_size": { - "type": "string" - }, - "max_standby_archive_delay": { - "type": "string" - }, - "max_standby_streaming_delay": { - "type": "string" - }, - "max_wal_size": { - "type": "string" - }, - "max_wal_senders": { - "$ref": "#/$defs/Union_2" - }, - "max_worker_processes": { - "$ref": "#/$defs/Union_2" - }, - "session_replication_role": { + "orioledb_version": { "type": "string", - "enum": [ - "origin", - "replica", - "local" - ], - "description": "Session replication role." - }, - "shared_buffers": { - "type": "string" - }, - "statement_timeout": { - "type": "string" + "description": "Postgres storage engine version for OrioleDB." }, - "track_activity_query_size": { - "type": "string" + "s3_host": { + "type": "string", + "description": "S3 bucket URL.", + "examples": [".s3-.amazonaws.com", "env(S3_HOST)"] }, - "track_commit_timestamp": { - "type": "boolean" + "s3_region": { + "type": "string", + "description": "S3 bucket region.", + "examples": ["us-east-1", "env(S3_REGION)"] }, - "wal_keep_size": { - "type": "string" + "s3_access_key": { + "type": "string", + "description": "S3 access key.", + "examples": ["env(S3_ACCESS_KEY)"] }, - "wal_sender_timeout": { - "type": "string" + "s3_secret_key": { + "type": "string", + "description": "S3 secret key.", + "examples": ["env(S3_SECRET_KEY)"] }, - "work_mem": { - "type": "string" - } - }, - "additionalProperties": false - }, - "Objects_17": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable management of network restrictions.", - "default": false + "webhooks": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable experimental webhooks.", + "default": false + } + }, + "additionalProperties": false }, - "allowed_cidrs": { - "type": "array", - "items": { - "type": "string" + "pgdelta": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use pg-delta as the schema diff engine for db diff / db pull / db remote commit. Set false to fall back to the legacy migra engine.", + "default": false + }, + "declarative_schema_path": { + "type": "string", + "description": "Directory under supabase/ where declarative schema files are written.", + "examples": ["./schemas"] + }, + "format_options": { + "type": "string", + "description": "JSON string passed through to pg-delta SQL formatting.", + "examples": [ + "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" + ] + } }, - "description": "Allowed IPv4 CIDR blocks.", - "default": [ - "0.0.0.0/0" - ] + "additionalProperties": false }, - "allowed_cidrs_v6": { - "type": "array", - "items": { - "type": "string" + "inspect": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Inspection query." + }, + "name": { + "type": "string", + "description": "Inspection rule name." + }, + "pass": { + "type": "string", + "description": "Success message." + }, + "fail": { + "type": "string", + "description": "Failure message." + } + }, + "additionalProperties": false + }, + "description": "Inspection rules.", + "default": [] + } }, - "description": "Allowed IPv6 CIDR blocks.", - "default": [ - "::/0" - ] - } - }, - "additionalProperties": false - }, - "Objects_18": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Reject non-secure connections to the database.", - "default": false + "additionalProperties": false } }, "additionalProperties": false }, - "Objects_19": { - "type": "object", - "additionalProperties": { - "type": "string", - "description": "Vault secret value." - }, - "description": "Vault secrets." - }, - "Objects_20": { - "type": "object", - "additionalProperties": { - "type": "string", - "description": "Secret value exposed to the edge runtime." - }, - "description": "Secrets exposed to the edge runtime." - }, - "Objects_21": { - "type": "object", - "patternProperties": { - "^[a-zA-Z0-9_-]+$": { - "anyOf": [ - { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Controls whether a function is deployed or served.", - "default": true - }, - "verify_jwt": { - "type": "boolean", - "description": "By default, deployed or locally served functions reject requests without a valid JWT.", - "default": true - }, - "import_map": { - "type": "string", - "description": "Import map file to use for the Function.", - "default": "" - }, - "entrypoint": { - "type": "string", - "description": "Entrypoint path to the Function. Defaults to \"functions/slug/index.ts\".", - "default": "" - }, - "static_files": { - "type": "array", - "items": { + "remotes": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "project_id": { "type": "string", - "description": "Static file glob for the function." + "description": "Remote project reference.", + "default": "" + }, + "analytics": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the local Logflare service.", + "default": true + }, + "port": { + "type": "number", + "description": "Port to the local Logflare service.", + "default": 54327 + }, + "backend": { + "type": "string", + "enum": ["postgres", "bigquery"], + "description": "Configure one of the supported backends:\n\n- `postgres`\n- `bigquery`", + "default": "postgres" + }, + "vector_port": { + "type": "number", + "description": "Port to the local syslog ingest service." + }, + "gcp_project_id": { + "type": "string", + "description": "GCP project ID." + }, + "gcp_project_number": { + "type": "string", + "description": "GCP project number." + }, + "gcp_jwt_path": { + "type": "string", + "description": "Path to the GCP JWT file." + } + }, + "additionalProperties": false + }, + "api": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the local PostgREST service.", + "default": true + }, + "port": { + "type": "number", + "description": "Port to use for the API URL.", + "default": 54321 + }, + "schemas": { + "type": "array", + "items": { + "type": "string", + "description": "Schemas to expose in your API. Tables, views and stored procedures in this schema will get API endpoints." + }, + "default": ["public", "graphql_public"] + }, + "extra_search_path": { + "type": "array", + "items": { + "type": "string", + "description": "Extra schemas to add to the search_path of every request." + }, + "default": ["public", "extensions"] + }, + "max_rows": { + "type": "number", + "description": "The maximum number of rows returned from a view, table, or stored procedure. Limits payload size for accidental or malicious requests.", + "default": 1000 + }, + "auto_expose_new_tables": { + "type": "boolean", + "description": "Controls whether newly-created tables, views, sequences and functions in the `public` schema by `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`) without explicit GRANTs. When unset, new entities are auto-exposed, matching the cloud default. Set to `false` to revoke the default Data API privileges so new entities require explicit GRANTs, matching a cloud project with the \"Default privileges for new entities\" toggle turned off." + }, + "tls": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable HTTPS endpoints locally using a self-signed certificate.", + "default": false + }, + "cert_path": { + "type": "string", + "description": "Path to the self-signed certificate." + }, + "key_path": { + "type": "string", + "description": "Path to the self-signed certificate private key." + } + }, + "additionalProperties": false + }, + "external_url": { + "type": "string", + "description": "External URL for accessing the API server." + } + }, + "additionalProperties": false + }, + "auth": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the local GoTrue service.", + "default": true + }, + "site_url": { + "type": "string", + "description": "The base URL of your website. Used as an allow-list for redirects and for constructing URLs used in emails.", + "default": "http://127.0.0.1:3000" + }, + "additional_redirect_urls": { + "type": "array", + "items": { + "type": "string", + "description": "A URL that auth providers are permitted to redirect to." + }, + "description": "A list of exact URLs that auth providers are permitted to redirect to post authentication.", + "default": ["https://127.0.0.1:3000"] + }, + "jwt_expiry": { + "type": "number", + "description": "How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one week).", + "default": 3600 + }, + "jwt_issuer": { + "type": "string", + "description": "JWT issuer URL." + }, + "signing_keys_path": { + "type": "string", + "description": "Path to the JWT signing keys file." + }, + "enable_refresh_token_rotation": { + "type": "boolean", + "description": "If disabled, the refresh token will never expire.", + "default": true + }, + "refresh_token_reuse_interval": { + "type": "number", + "description": "Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.", + "default": 10 + }, + "enable_manual_linking": { + "type": "boolean", + "description": "Allow/disallow testing manual linking of accounts.", + "default": false + }, + "enable_signup": { + "type": "boolean", + "description": "Allow/disallow new user signups to your project.", + "default": true + }, + "enable_anonymous_sign_ins": { + "type": "boolean", + "description": "Allow/disallow anonymous sign-ins to your project.", + "default": false + }, + "minimum_password_length": { + "type": "number", + "description": "Passwords shorter than this value will be rejected as weak.", + "default": 6 + }, + "password_requirements": { + "type": "string", + "enum": [ + "", + "letters_digits", + "lower_upper_letters_digits", + "lower_upper_letters_digits_symbols" + ], + "description": "Password character requirements.", + "default": "" + }, + "publishable_key": { + "type": "string", + "description": "Publishable key override." + }, + "secret_key": { + "type": "string", + "description": "Secret key override." + }, + "jwt_secret": { + "type": "string", + "description": "JWT secret override." + }, + "anon_key": { + "type": "string", + "description": "Anon key override." + }, + "service_role_key": { + "type": "string", + "description": "Service role key override." + }, + "rate_limit": { + "type": "object", + "properties": { + "email_sent": { + "type": "number", + "description": "Number of emails that can be sent per hour.", + "default": 2 + }, + "sms_sent": { + "type": "number", + "description": "Number of SMS messages that can be sent per hour.", + "default": 30 + }, + "anonymous_users": { + "type": "number", + "description": "Number of anonymous sign-ins that can be made per hour per IP address.", + "default": 30 + }, + "token_refresh": { + "type": "number", + "description": "Number of sessions that can be refreshed in a 5 minute interval per IP address.", + "default": 150 + }, + "sign_in_sign_ups": { + "type": "number", + "description": "Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address.", + "default": 30 + }, + "token_verifications": { + "type": "number", + "description": "Number of OTP or magic link verifications that can be made in a 5 minute interval per IP address.", + "default": 30 + }, + "web3": { + "type": "number", + "description": "Number of Web3 logins that can be made in a 5 minute interval per IP address.", + "default": 30 + } + }, + "additionalProperties": false + }, + "captcha": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable CAPTCHA verification.", + "default": false + }, + "provider": { + "type": "string", + "enum": ["hcaptcha", "turnstile"], + "description": "CAPTCHA provider to use." + }, + "secret": { + "type": "string", + "description": "Secret key for the CAPTCHA provider." + } + }, + "additionalProperties": false + }, + "hook": { + "type": "object", + "properties": { + "mfa_verification_attempt": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable the mfa verification hook.", + "default": false + }, + "uri": { + "type": "string", + "description": "The URI of the postgres function or HTTP endpoint to call." + }, + "secrets": { + "type": "string", + "description": "Secret value to pass to the function or endpoint." + } + }, + "additionalProperties": false + }, + "password_verification_attempt": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable the password verification hook.", + "default": false + }, + "uri": { + "type": "string", + "description": "The URI of the postgres function or HTTP endpoint to call." + }, + "secrets": { + "type": "string", + "description": "Secret value to pass to the function or endpoint." + } + }, + "additionalProperties": false + }, + "custom_access_token": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable the custom access token hook.", + "default": false + }, + "uri": { + "type": "string", + "description": "The URI of the postgres function or HTTP endpoint to call." + }, + "secrets": { + "type": "string", + "description": "Secret value to pass to the function or endpoint." + } + }, + "additionalProperties": false + }, + "send_sms": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable the send sms hook.", + "default": false + }, + "uri": { + "type": "string", + "description": "The URI of the postgres function or HTTP endpoint to call." + }, + "secrets": { + "type": "string", + "description": "Secret value to pass to the function or endpoint." + } + }, + "additionalProperties": false + }, + "send_email": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable the send email hook.", + "default": false + }, + "uri": { + "type": "string", + "description": "The URI of the postgres function or HTTP endpoint to call." + }, + "secrets": { + "type": "string", + "description": "Secret value to pass to the function or endpoint." + } + }, + "additionalProperties": false + }, + "before_user_created": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable the before user created hook.", + "default": false + }, + "uri": { + "type": "string", + "description": "The URI of the postgres function or HTTP endpoint to call." + }, + "secrets": { + "type": "string", + "description": "Secret value to pass to the function or endpoint." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "mfa": { + "type": "object", + "properties": { + "totp": { + "type": "object", + "properties": { + "enroll_enabled": { + "type": "boolean", + "description": "Allow/disallow TOTP enrollment for users.", + "default": false + }, + "verify_enabled": { + "type": "boolean", + "description": "Allow/disallow TOTP verification for users.", + "default": false + } + }, + "additionalProperties": false + }, + "phone": { + "type": "object", + "properties": { + "enroll_enabled": { + "type": "boolean", + "description": "Allow/disallow phone enrollment for users.", + "default": false + }, + "verify_enabled": { + "type": "boolean", + "description": "Allow/disallow phone verification for users.", + "default": false + }, + "otp_length": { + "type": "number", + "description": "The length of the OTP code.", + "default": 6 + }, + "template": { + "type": "string", + "description": "The template to use for the phone message.", + "default": "Your code is {{ .Code }}" + }, + "max_frequency": { + "type": "string", + "description": "The maximum frequency of the phone messages.", + "default": "5s" + } + }, + "additionalProperties": false + }, + "web_authn": { + "type": "object", + "properties": { + "enroll_enabled": { + "type": "boolean", + "description": "Allow/disallow WebAuthn enrollment for users.", + "default": false + }, + "verify_enabled": { + "type": "boolean", + "description": "Allow/disallow WebAuthn verification for users.", + "default": false + } + }, + "additionalProperties": false + }, + "max_enrolled_factors": { + "type": "number", + "description": "The maximum number of MFA factors a user can enroll in.", + "default": 10 + } + }, + "additionalProperties": false + }, + "sessions": { + "type": "object", + "properties": { + "timebox": { + "type": "string", + "description": "The timebox for the user session." + }, + "inactivity_timeout": { + "type": "string", + "description": "The inactivity timeout for the user session." + } + }, + "additionalProperties": false, + "default": {} + }, + "email": { + "type": "object", + "properties": { + "enable_signup": { + "type": "boolean", + "description": "Allow/disallow new user signups via email to your project.", + "default": true + }, + "double_confirm_changes": { + "type": "boolean", + "description": "If enabled, a user will be required to confirm any email change on both the old and new email addresses.", + "default": true + }, + "enable_confirmations": { + "type": "boolean", + "description": "If enabled, users need to confirm their email address before signing in.", + "default": false + }, + "secure_password_change": { + "type": "boolean", + "description": "If enabled, users will need to reauthenticate or have logged in recently to change their password.", + "default": false + }, + "max_frequency": { + "type": "string", + "description": "Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.", + "default": "1s" + }, + "otp_length": { + "type": "number", + "description": "Number of characters used in the email OTP.", + "default": 6 + }, + "otp_expiry": { + "type": "number", + "description": "Number of seconds before the email OTP expires.", + "default": 3600 + }, + "smtp": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable SMTP for email delivery.", + "default": false + }, + "host": { + "type": "string", + "description": "Hostname or IP address of the SMTP server." + }, + "port": { + "type": "number", + "description": "Port number of the SMTP server." + }, + "user": { + "type": "string", + "description": "Username for authenticating with the SMTP server." + }, + "pass": { + "type": "string", + "description": "Password for authenticating with the SMTP server." + }, + "admin_email": { + "type": "string", + "description": "Email used as the sender for emails sent from the application." + }, + "sender_name": { + "type": "string", + "description": "Display name used as the sender for emails sent from the application." + } + }, + "additionalProperties": false + }, + "template": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "subject": { + "type": "string", + "description": "Subject line for the email template.", + "default": "" + }, + "content_path": { + "type": "string", + "description": "Path to the HTML template.", + "default": "" + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "description": "Custom email template configuration.", + "default": {} + }, + { + "type": "null" + } + ] + }, + "notification": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the notification email.", + "default": false + }, + "subject": { + "type": "string", + "description": "Subject line for the notification email.", + "default": "" + }, + "content_path": { + "type": "string", + "description": "Path to the HTML notification template.", + "default": "" + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "description": "Notification email configuration.", + "default": {} + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "sms": { + "type": "object", + "properties": { + "enable_signup": { + "type": "boolean", + "description": "Allow/disallow new user signups via SMS to your project.", + "default": false + }, + "enable_confirmations": { + "type": "boolean", + "description": "If enabled, users need to confirm their phone number before signing in.", + "default": false + }, + "template": { + "type": "string", + "description": "The template to use for the SMS message.", + "default": "Your code is {{ .Code }}" + }, + "max_frequency": { + "type": "string", + "description": "Controls the minimum amount of time that must pass before sending another sms otp.", + "default": "5s" + }, + "twilio": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable Twilio provider for phone login.", + "default": false + }, + "account_sid": { + "type": "string", + "description": "The account SID for the Twilio API.", + "default": "" + }, + "message_service_sid": { + "type": "string", + "description": "The message service SID for the Twilio API.", + "default": "" + }, + "auth_token": { + "type": "string", + "description": "The auth token for the Twilio API.", + "examples": ["env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)"] + } + }, + "additionalProperties": false + }, + "twilio_verify": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable Twilio Verify provider for phone verification.", + "default": false + }, + "account_sid": { + "type": "string", + "description": "The account SID for the Twilio API." + }, + "message_service_sid": { + "type": "string", + "description": "The message service SID for the Twilio API." + }, + "auth_token": { + "type": "string", + "description": "The auth token for the Twilio API." + } + }, + "additionalProperties": false + }, + "messagebird": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable MessageBird provider for phone login.", + "default": false + }, + "originator": { + "type": "string", + "description": "The originator of the SMS message." + }, + "access_key": { + "type": "string", + "description": "The access key for the MessageBird API." + } + }, + "additionalProperties": false + }, + "textlocal": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable Textlocal provider for phone login.", + "default": false + }, + "sender": { + "type": "string", + "description": "The sender of the SMS message." + }, + "api_key": { + "type": "string", + "description": "The API key for the Textlocal API." + } + }, + "additionalProperties": false + }, + "vonage": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable Vonage provider for phone login.", + "default": false + }, + "from": { + "type": "string", + "description": "The sender of the SMS message." + }, + "api_key": { + "type": "string", + "description": "The API key for the Vonage API." + }, + "api_secret": { + "type": "string", + "description": "The API secret for the Vonage API." + } + }, + "additionalProperties": false + }, + "test_otp": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Use pre-defined map of phone number to OTP for testing." + } + }, + "additionalProperties": false + }, + "external": { + "type": "object", + "properties": { + "apple": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Apple OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Apple OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Apple OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Apple OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + }, + "azure": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Azure OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Azure OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Azure OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_AZURE_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Azure OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + }, + "bitbucket": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Bitbucket OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Bitbucket OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Bitbucket OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_BITBUCKET_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Bitbucket OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + }, + "discord": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Discord OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Discord OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Discord OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_DISCORD_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Discord OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + }, + "facebook": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Facebook OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Facebook OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Facebook OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_FACEBOOK_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Facebook OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + }, + "github": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the GitHub OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the GitHub OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the GitHub OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the GitHub OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + }, + "gitlab": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the GitLab OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the GitLab OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the GitLab OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_GITLAB_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "https://gitlab.com" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the GitLab OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + }, + "google": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Google OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Google OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Google OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_GOOGLE_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Google OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + }, + "kakao": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Kakao OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Kakao OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Kakao OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_KAKAO_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Kakao OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + }, + "keycloak": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Keycloak OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Keycloak OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Keycloak OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_KEYCLOAK_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "", + "examples": ["https://keycloak.example.com/realms/myrealm"] + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Keycloak OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + }, + "linkedin_oidc": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the LinkedIn OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the LinkedIn OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the LinkedIn OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_LINKEDIN_OIDC_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the LinkedIn OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + }, + "notion": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Notion OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Notion OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Notion OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_NOTION_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Notion OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + }, + "twitch": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Twitch OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Twitch OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Twitch OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_TWITCH_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Twitch OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + }, + "twitter": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Twitter OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Twitter OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Twitter OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_TWITTER_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Twitter OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + }, + "x": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the X OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the X OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the X OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_X_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the X OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + }, + "slack_oidc": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Slack OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Slack OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Slack OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_SLACK_OIDC_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Slack OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + }, + "spotify": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Spotify OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Spotify OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Spotify OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_SPOTIFY_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Spotify OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + }, + "workos": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the WorkOS OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the WorkOS OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the WorkOS OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_WORKOS_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the WorkOS OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + }, + "zoom": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Zoom OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Zoom OAuth provider.", + "default": "" + }, + "secret": { + "type": "string", + "description": "Client secret for the Zoom OAuth provider.\n\nDO NOT commit your OAuth provider secret to git. Use environment variable substitution instead.", + "examples": ["env(SUPABASE_AUTH_EXTERNAL_ZOOM_SECRET)"] + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Zoom OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "web3": { + "type": "object", + "properties": { + "solana": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this Web3 provider.", + "default": false + } + }, + "additionalProperties": false + }, + "ethereum": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this Web3 provider.", + "default": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + }, + "oauth_server": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable OAuth server functionality.", + "default": false + }, + "authorization_url_path": { + "type": "string", + "description": "Path for OAuth consent flow UI.", + "default": "/oauth/consent" + }, + "allow_dynamic_registration": { + "type": "boolean", + "description": "Allow dynamic client registration.", + "default": false + } + }, + "additionalProperties": false + }, + "third_party": { + "type": "object", + "properties": { + "firebase": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this third-party auth provider.", + "default": false + }, + "project_id": { + "type": "string", + "description": "Firebase project ID." + } + }, + "additionalProperties": false + }, + "auth0": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this third-party auth provider.", + "default": false + }, + "tenant": { + "type": "string", + "description": "Auth0 tenant." + }, + "tenant_region": { + "type": "string", + "description": "Auth0 tenant region." + } + }, + "additionalProperties": false + }, + "aws_cognito": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this third-party auth provider.", + "default": false + }, + "user_pool_id": { + "type": "string", + "description": "AWS Cognito user pool ID." + }, + "user_pool_region": { + "type": "string", + "description": "AWS Cognito user pool region." + } + }, + "additionalProperties": false + }, + "clerk": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this third-party auth provider.", + "default": false + }, + "domain": { + "type": "string", + "description": "Clerk domain." + } + }, + "additionalProperties": false + }, + "workos": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this third-party auth provider.", + "default": false + }, + "issuer_url": { + "type": "string", + "description": "WorkOS issuer URL." + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false }, - "description": "Static files to bundle with the function.", - "default": [] - }, - "env": { - "type": "object", - "patternProperties": { - "^[A-Z_][A-Z0-9_]*$": { - "type": "string", - "allOf": [ - { - "pattern": "^env\\((.*)\\)$", - "description": "Reference to a project environment variable available to the Function." - } - ] - } + "db": { + "type": "object", + "properties": { + "port": { + "type": "number", + "description": "Port to use for the local database URL.", + "default": 54322 + }, + "shadow_port": { + "type": "number", + "description": "Port used by db diff command to initialize the shadow database.", + "default": 54320 + }, + "health_timeout": { + "type": "string", + "description": "Maximum amount of time to wait for health check when starting the local database.", + "default": "2m" + }, + "major_version": { + "type": "number", + "description": "The database major version to use. This has to be the same as your remote database's.", + "default": 17 + }, + "pooler": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the local PgBouncer service.", + "default": false + }, + "port": { + "type": "number", + "description": "Port to use for the local connection pooler.", + "default": 54329 + }, + "pool_mode": { + "type": "string", + "enum": ["transaction", "session"], + "description": "Specifies when a server connection can be reused by other clients.", + "default": "transaction" + }, + "default_pool_size": { + "type": "number", + "description": "How many server connections to allow per user/database pair.", + "default": 20 + }, + "max_client_conn": { + "type": "number", + "description": "Maximum number of client connections allowed.", + "default": 100 + } + }, + "additionalProperties": false + }, + "migrations": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "If disabled, migrations will be skipped during a db push or reset.", + "default": true + }, + "schema_paths": { + "type": "array", + "items": { + "type": "string", + "description": "Schema file path, directory, or glob relative to the supabase directory." + }, + "description": "Ordered list of schema files, directories, or glob patterns that describe your database.", + "default": [] + } + }, + "additionalProperties": false + }, + "seed": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable seeding the database with SQL files.", + "default": true + }, + "sql_paths": { + "type": "array", + "items": { + "type": "string", + "description": "Path to a SQL file used to seed the database." + }, + "description": "Ordered list of seed files to load during db reset.", + "default": ["./seed.sql"] + } + }, + "additionalProperties": false + }, + "settings": { + "type": "object", + "properties": { + "effective_cache_size": { + "type": "string" + }, + "logical_decoding_work_mem": { + "type": "string" + }, + "maintenance_work_mem": { + "type": "string" + }, + "max_connections": { + "type": "number" + }, + "max_locks_per_transaction": { + "type": "number" + }, + "max_parallel_maintenance_workers": { + "type": "number" + }, + "max_parallel_workers": { + "type": "number" + }, + "max_parallel_workers_per_gather": { + "type": "number" + }, + "max_replication_slots": { + "type": "number" + }, + "max_slot_wal_keep_size": { + "type": "string" + }, + "max_standby_archive_delay": { + "type": "string" + }, + "max_standby_streaming_delay": { + "type": "string" + }, + "max_wal_size": { + "type": "string" + }, + "max_wal_senders": { + "type": "number" + }, + "max_worker_processes": { + "type": "number" + }, + "session_replication_role": { + "type": "string", + "enum": ["origin", "replica", "local"], + "description": "Session replication role." + }, + "shared_buffers": { + "type": "string" + }, + "statement_timeout": { + "type": "string" + }, + "track_activity_query_size": { + "type": "string" + }, + "track_commit_timestamp": { + "type": "boolean" + }, + "wal_keep_size": { + "type": "string" + }, + "wal_sender_timeout": { + "type": "string" + }, + "work_mem": { + "type": "string" + } + }, + "additionalProperties": false + }, + "network_restrictions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable management of network restrictions.", + "default": false + }, + "allowed_cidrs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Allowed IPv4 CIDR blocks.", + "default": ["0.0.0.0/0"] + }, + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Allowed IPv6 CIDR blocks.", + "default": ["::/0"] + } + }, + "additionalProperties": false + }, + "ssl_enforcement": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Reject non-secure connections to the database.", + "default": false + } + }, + "additionalProperties": false + }, + "vault": { + "type": "object", + "additionalProperties": { + "type": "string", + "description": "Vault secret value." + }, + "description": "Vault secrets." + } + }, + "additionalProperties": false }, - "description": "Environment variables from the project environment that this Function can access.", - "default": {} - } - }, - "additionalProperties": false - }, - { - "type": "null" - } - ] - } - }, - "description": "Function-specific configuration keyed by function slug.", - "default": {} - }, - "Objects_22": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable image transformation.", - "default": false - } - }, - "additionalProperties": false - }, - "Objects_23": { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "type": "object", - "properties": { - "public": { - "type": "boolean", - "description": "Enable public access to the bucket.", - "default": false - }, - "file_size_limit": { - "anyOf": [ - { - "type": "string" + "edge_runtime": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the local Edge Runtime service.", + "default": true + }, + "policy": { + "type": "string", + "enum": ["oneshot", "per_worker"], + "description": "Configure the supported request policy.", + "default": "per_worker" + }, + "inspector_port": { + "type": "number", + "description": "Port to run the Edge Functions inspector on.", + "default": 8083 + }, + "deno_version": { + "type": "number", + "description": "The Deno major version to use.", + "default": 2 + }, + "secrets": { + "type": "object", + "additionalProperties": { + "type": "string", + "description": "Secret value exposed to the edge runtime." + }, + "description": "Secrets exposed to the edge runtime." + } + }, + "additionalProperties": false + }, + "functions": { + "anyOf": [ + { + "type": "object", + "patternProperties": { + "^[a-zA-Z0-9_-]+$": { + "anyOf": [ + { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Controls whether a function is deployed or served.", + "default": true + }, + "verify_jwt": { + "type": "boolean", + "description": "By default, deployed or locally served functions reject requests without a valid JWT.", + "default": true + }, + "import_map": { + "type": "string", + "description": "Import map file to use for the Function.", + "default": "" + }, + "entrypoint": { + "type": "string", + "description": "Entrypoint path to the Function. Defaults to \"functions/slug/index.ts\".", + "default": "" + }, + "static_files": { + "type": "array", + "items": { + "type": "string", + "description": "Static file glob for the function." + }, + "description": "Static files to bundle with the function.", + "default": [] + }, + "env": { + "type": "object", + "patternProperties": { + "^[A-Z_][A-Z0-9_]*$": { + "type": "string", + "pattern": "^env\\((.*)\\)$", + "description": "Reference to a project environment variable available to the Function." + } + }, + "description": "Environment variables from the project environment that this Function can access.", + "default": {} + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + } + }, + "description": "Function-specific configuration keyed by function slug.", + "default": {} + }, + { + "type": "null" + } + ] + }, + "local_smtp": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the local SMTP testing server.", + "default": true + }, + "port": { + "type": "number", + "description": "Port to use for the email testing server web interface.\n\nEmails sent with the local dev setup are monitored and available from the web interface.", + "default": 54324 + }, + "smtp_port": { + "type": "number", + "description": "Optional SMTP port to expose for local testing." + }, + "pop3_port": { + "type": "number", + "description": "Optional POP3 port to expose for local testing." + }, + "admin_email": { + "type": "string", + "description": "Admin email address for test email sender metadata." + }, + "sender_name": { + "type": "string", + "description": "Sender name for test email sender metadata." + } + }, + "additionalProperties": false + }, + "realtime": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the local Realtime service.", + "default": true + }, + "ip_version": { + "type": "string", + "enum": ["IPv4", "IPv6"], + "description": "Bind realtime via either IPv4 or IPv6.", + "default": "IPv4" + }, + "max_header_length": { + "type": "number", + "description": "Maximum length of the HTTP header.", + "default": 4096 + } + }, + "additionalProperties": false + }, + "storage": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the local Storage service.", + "default": true + }, + "file_size_limit": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "image_transformation": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable image transformation.", + "default": false + } + }, + "additionalProperties": false + }, + "buckets": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object", + "properties": { + "public": { + "type": "boolean", + "description": "Enable public access to the bucket.", + "default": false + }, + "file_size_limit": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "number" + } + ] + }, + "allowed_mime_types": { + "type": "array", + "items": { + "type": "string", + "description": "A MIME type allowed for the bucket." + }, + "description": "The list of allowed MIME types for the bucket.", + "default": [] + }, + "objects_path": { + "type": "string", + "description": "The path to the objects in the bucket.", + "default": "" + } + }, + "additionalProperties": false + }, + { + "type": "null" + } + ] + }, + "description": "Storage buckets configuration." + }, + "s3_protocol": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Allow connections via S3 compatible clients.", + "default": true + } + }, + "additionalProperties": false + }, + "analytics": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable analytics buckets.", + "default": false + }, + "max_namespaces": { + "type": "number", + "description": "Maximum number of analytics namespaces.", + "default": 5 + }, + "max_tables": { + "type": "number", + "description": "Maximum number of analytics tables.", + "default": 10 + }, + "max_catalogs": { + "type": "number", + "description": "Maximum number of analytics catalogs.", + "default": 2 + }, + "buckets": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + }, + { + "type": "null" + } + ] + }, + "description": "Analytics bucket configuration.", + "default": {} + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + }, + "vector": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable vector buckets.", + "default": true + }, + "max_buckets": { + "type": "number", + "description": "Maximum number of vector buckets.", + "default": 10 + }, + "max_indexes": { + "type": "number", + "description": "Maximum number of vector indexes.", + "default": 5 + }, + "buckets": { + "anyOf": [ + { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + }, + { + "type": "null" + } + ] + }, + "description": "Vector bucket configuration.", + "default": {} + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false }, - { - "anyOf": [ - { - "type": "number" + "studio": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the local Supabase Studio dashboard.", + "default": true }, - { - "$ref": "#/$defs/Union_" - } - ] - } - ] - }, - "allowed_mime_types": { - "type": "array", - "items": { - "type": "string", - "description": "A MIME type allowed for the bucket." - }, - "description": "The list of allowed MIME types for the bucket.", - "default": [] - }, - "objects_path": { - "type": "string", - "description": "The path to the objects in the bucket.", - "default": "" - } - }, - "additionalProperties": false - }, - { - "type": "null" - } - ] - }, - "description": "Storage buckets configuration." - }, - "Objects_24": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Allow connections via S3 compatible clients.", - "default": true - } - }, - "additionalProperties": false - }, - "Objects_25": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable analytics buckets.", - "default": false - }, - "max_namespaces": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "max_tables": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "max_catalogs": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "buckets": { - "anyOf": [ - { - "type": "object", - "additionalProperties": { - "anyOf": [ - { - "anyOf": [ - { - "type": "object" + "port": { + "type": "number", + "description": "Port to use for Supabase Studio.", + "default": 54323 }, - { - "type": "array" + "api_url": { + "type": "string", + "description": "External URL of the API server that frontend connects to.", + "default": "http://127.0.0.1" + }, + "openai_api_key": { + "type": "string", + "description": "OpenAI API key to use for Supabase AI in the Supabase Studio.", + "examples": ["env(OPENAI_API_KEY)"] } - ] + }, + "additionalProperties": false }, - { - "type": "null" - } - ] - }, - "description": "Analytics bucket configuration.", - "default": {} - }, - { - "type": "null" - } - ] - } - }, - "additionalProperties": false - }, - "Objects_26": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable vector buckets.", - "default": true - }, - "max_buckets": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "max_indexes": { - "anyOf": [ - { - "type": "number" - }, - { - "$ref": "#/$defs/Union_" - } - ] - }, - "buckets": { - "anyOf": [ - { - "type": "object", - "additionalProperties": { - "anyOf": [ - { + "workers": { "anyOf": [ { - "type": "object" + "type": "object", + "patternProperties": { + "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$": { + "type": "object", + "properties": { + "runtime": { + "type": "string", + "description": "Runtime the worker is built on: `dockerfile` to build the directory's own\nDockerfile, or one of the catalog runtimes (`node`, `deno`). Guessed from\nmarker files when unset.", + "examples": ["node"] + }, + "size": { + "type": "string", + "description": "Instance size, denominated by memory. Each size implies its own vCPU count,\nso it is the one dial rather than two.", + "examples": ["2gb"] + }, + "instances": { + "type": "integer", + "minimum": 0, + "description": "Number of instances to run. Every deploy sends a complete spec, so a count\nrecorded here is what keeps a scaled worker scaled; `--instances` overrides\nit for one deploy. Defaults to 1.", + "examples": [3] + }, + "source": { + "type": "string", + "description": "Directory holding the worker's code, relative to the project root, when it\ndoes not live at `supabase/workers//`.", + "examples": ["packages/api"] + } + }, + "additionalProperties": false + } + }, + "description": "Worker-specific configuration keyed by worker name.", + "default": {} }, { - "type": "array" + "type": "null" } ] }, - { - "type": "null" + "experimental": { + "type": "object", + "properties": { + "orioledb_version": { + "type": "string", + "description": "Postgres storage engine version for OrioleDB." + }, + "s3_host": { + "type": "string", + "description": "S3 bucket URL.", + "examples": [".s3-.amazonaws.com", "env(S3_HOST)"] + }, + "s3_region": { + "type": "string", + "description": "S3 bucket region.", + "examples": ["us-east-1", "env(S3_REGION)"] + }, + "s3_access_key": { + "type": "string", + "description": "S3 access key.", + "examples": ["env(S3_ACCESS_KEY)"] + }, + "s3_secret_key": { + "type": "string", + "description": "S3 secret key.", + "examples": ["env(S3_SECRET_KEY)"] + }, + "webhooks": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable experimental webhooks.", + "default": false + } + }, + "additionalProperties": false + }, + "pgdelta": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use pg-delta as the schema diff engine for db diff / db pull / db remote commit. Set false to fall back to the legacy migra engine.", + "default": false + }, + "declarative_schema_path": { + "type": "string", + "description": "Directory under supabase/ where declarative schema files are written.", + "examples": ["./schemas"] + }, + "format_options": { + "type": "string", + "description": "JSON string passed through to pg-delta SQL formatting.", + "examples": [ + "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" + ] + } + }, + "additionalProperties": false + }, + "inspect": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Inspection query." + }, + "name": { + "type": "string", + "description": "Inspection rule name." + }, + "pass": { + "type": "string", + "description": "Success message." + }, + "fail": { + "type": "string", + "description": "Failure message." + } + }, + "additionalProperties": false + }, + "description": "Inspection rules.", + "default": [] + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false } - ] - }, - "description": "Vector bucket configuration.", - "default": {} - }, - { - "type": "null" - } - ] - } - }, - "additionalProperties": false - }, - "Objects_27": { - "type": "object", - "patternProperties": { - "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$": { - "type": "object", - "properties": { - "runtime": { - "type": "string", - "description": "Runtime the worker is built on: `dockerfile` to build the directory's own\nDockerfile, or one of the catalog runtimes (`node`, `deno`). Guessed from\nmarker files when unset.", - "examples": [ - "node" - ] - }, - "size": { - "type": "string", - "description": "Instance size, denominated by memory. Each size implies its own vCPU count,\nso it is the one dial rather than two.", - "examples": [ - "2gb" - ] - }, - "instances": { - "type": "integer", - "allOf": [ - { - "minimum": 0, - "description": "Number of instances to run. Every deploy sends a complete spec, so a count\nrecorded here is what keeps a scaled worker scaled; `--instances` overrides\nit for one deploy. Defaults to 1.", - "examples": [ - 3 - ] - } - ] - }, - "source": { - "type": "string", - "description": "Directory holding the worker's code, relative to the project root, when it\ndoes not live at `supabase/workers//`.", - "examples": [ - "packages/api" - ] - } - }, - "additionalProperties": false - } - }, - "description": "Worker-specific configuration keyed by worker name.", - "default": {} - }, - "Objects_28": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Enable experimental webhooks.", - "default": false - } - }, - "additionalProperties": false - }, - "Objects_29": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean", - "description": "Use pg-delta as the schema diff engine for db diff / db pull / db remote commit. Set false to fall back to the legacy migra engine.", - "default": false - }, - "declarative_schema_path": { - "type": "string", - "description": "Directory under supabase/ where declarative schema files are written.", - "examples": [ - "./schemas" - ] - }, - "format_options": { - "type": "string", - "description": "JSON string passed through to pg-delta SQL formatting.", - "examples": [ - "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" - ] - } - }, - "additionalProperties": false - }, - "Objects_30": { - "type": "object", - "properties": { - "rules": { - "type": "array", - "items": { - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "Inspection query." - }, - "name": { - "type": "string", - "description": "Inspection rule name." - }, - "pass": { - "type": "string", - "description": "Success message." + }, + "additionalProperties": false }, - "fail": { - "type": "string", - "description": "Failure message." + { + "type": "null" } - }, - "additionalProperties": false + ] }, - "description": "Inspection rules.", - "default": [] + "description": "Remote branch-specific project configuration.", + "default": {} + }, + { + "type": "null" } - }, - "additionalProperties": false + ] } - } + }, + "additionalProperties": false } diff --git a/apps/docs/public/cli/project-config.schema.json b/apps/docs/public/cli/project-config.schema.json new file mode 100644 index 0000000000..d05346ae77 --- /dev/null +++ b/apps/docs/public/cli/project-config.schema.json @@ -0,0 +1,1972 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://supabase.com/docs/cli/project-config.schema.json", + "title": "Supabase hosted project config (ProjectConfig)", + "description": "The sparse, hosted-project subset of CliConfig that a Supabase project manages.", + "type": "object", + "properties": { + "api": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the local PostgREST service.", + "default": true + }, + "port": { + "type": "number", + "description": "Port to use for the API URL.", + "default": 54321 + }, + "schemas": { + "type": "array", + "items": { + "type": "string", + "description": "Schemas to expose in your API. Tables, views and stored procedures in this schema will get API endpoints." + }, + "default": ["public", "graphql_public"] + }, + "extra_search_path": { + "type": "array", + "items": { + "type": "string", + "description": "Extra schemas to add to the search_path of every request." + }, + "default": ["public", "extensions"] + }, + "max_rows": { + "type": "number", + "description": "The maximum number of rows returned from a view, table, or stored procedure. Limits payload size for accidental or malicious requests.", + "default": 1000 + }, + "auto_expose_new_tables": { + "type": "boolean", + "description": "Controls whether newly-created tables, views, sequences and functions in the `public` schema by `postgres` are reachable through the Data API roles (`anon`, `authenticated`, `service_role`) without explicit GRANTs. When unset, new entities are auto-exposed, matching the cloud default. Set to `false` to revoke the default Data API privileges so new entities require explicit GRANTs, matching a cloud project with the \"Default privileges for new entities\" toggle turned off." + }, + "tls": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable HTTPS endpoints locally using a self-signed certificate.", + "default": false + }, + "cert_path": { + "type": "string", + "description": "Path to the self-signed certificate." + }, + "key_path": { + "type": "string", + "description": "Path to the self-signed certificate private key." + } + }, + "additionalProperties": true + }, + "external_url": { + "type": "string", + "description": "External URL for accessing the API server." + } + }, + "additionalProperties": true + }, + "auth": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the local GoTrue service.", + "default": true + }, + "site_url": { + "type": "string", + "description": "The base URL of your website. Used as an allow-list for redirects and for constructing URLs used in emails.", + "default": "http://127.0.0.1:3000" + }, + "additional_redirect_urls": { + "type": "array", + "items": { + "type": "string", + "description": "A URL that auth providers are permitted to redirect to." + }, + "description": "A list of exact URLs that auth providers are permitted to redirect to post authentication.", + "default": ["https://127.0.0.1:3000"] + }, + "jwt_expiry": { + "type": "number", + "description": "How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 seconds (one week).", + "default": 3600 + }, + "jwt_issuer": { + "type": "string", + "description": "JWT issuer URL." + }, + "signing_keys_path": { + "type": "string", + "description": "Path to the JWT signing keys file." + }, + "enable_refresh_token_rotation": { + "type": "boolean", + "description": "If disabled, the refresh token will never expire.", + "default": true + }, + "refresh_token_reuse_interval": { + "type": "number", + "description": "Allows refresh tokens to be reused after expiry, up to the specified interval in seconds.", + "default": 10 + }, + "enable_manual_linking": { + "type": "boolean", + "description": "Allow/disallow testing manual linking of accounts.", + "default": false + }, + "enable_signup": { + "type": "boolean", + "description": "Allow/disallow new user signups to your project.", + "default": true + }, + "enable_anonymous_sign_ins": { + "type": "boolean", + "description": "Allow/disallow anonymous sign-ins to your project.", + "default": false + }, + "minimum_password_length": { + "type": "number", + "description": "Passwords shorter than this value will be rejected as weak.", + "default": 6 + }, + "password_requirements": { + "type": "string", + "enum": [ + "", + "letters_digits", + "lower_upper_letters_digits", + "lower_upper_letters_digits_symbols" + ], + "description": "Password character requirements.", + "default": "" + }, + "rate_limit": { + "type": "object", + "properties": { + "email_sent": { + "type": "number", + "description": "Number of emails that can be sent per hour.", + "default": 2 + }, + "sms_sent": { + "type": "number", + "description": "Number of SMS messages that can be sent per hour.", + "default": 30 + }, + "anonymous_users": { + "type": "number", + "description": "Number of anonymous sign-ins that can be made per hour per IP address.", + "default": 30 + }, + "token_refresh": { + "type": "number", + "description": "Number of sessions that can be refreshed in a 5 minute interval per IP address.", + "default": 150 + }, + "sign_in_sign_ups": { + "type": "number", + "description": "Number of sign up and sign-in requests that can be made in a 5 minute interval per IP address.", + "default": 30 + }, + "token_verifications": { + "type": "number", + "description": "Number of OTP or magic link verifications that can be made in a 5 minute interval per IP address.", + "default": 30 + }, + "web3": { + "type": "number", + "description": "Number of Web3 logins that can be made in a 5 minute interval per IP address.", + "default": 30 + } + }, + "additionalProperties": true + }, + "captcha": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable CAPTCHA verification.", + "default": false + }, + "provider": { + "type": "string", + "enum": ["hcaptcha", "turnstile"], + "description": "CAPTCHA provider to use." + } + }, + "additionalProperties": true + }, + "hook": { + "type": "object", + "properties": { + "mfa_verification_attempt": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable the mfa verification hook.", + "default": false + }, + "uri": { + "type": "string", + "description": "The URI of the postgres function or HTTP endpoint to call." + } + }, + "additionalProperties": true + }, + "password_verification_attempt": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable the password verification hook.", + "default": false + }, + "uri": { + "type": "string", + "description": "The URI of the postgres function or HTTP endpoint to call." + } + }, + "additionalProperties": true + }, + "custom_access_token": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable the custom access token hook.", + "default": false + }, + "uri": { + "type": "string", + "description": "The URI of the postgres function or HTTP endpoint to call." + } + }, + "additionalProperties": true + }, + "send_sms": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable the send sms hook.", + "default": false + }, + "uri": { + "type": "string", + "description": "The URI of the postgres function or HTTP endpoint to call." + } + }, + "additionalProperties": true + }, + "send_email": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable the send email hook.", + "default": false + }, + "uri": { + "type": "string", + "description": "The URI of the postgres function or HTTP endpoint to call." + } + }, + "additionalProperties": true + }, + "before_user_created": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable or disable the before user created hook.", + "default": false + }, + "uri": { + "type": "string", + "description": "The URI of the postgres function or HTTP endpoint to call." + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "mfa": { + "type": "object", + "properties": { + "totp": { + "type": "object", + "properties": { + "enroll_enabled": { + "type": "boolean", + "description": "Allow/disallow TOTP enrollment for users.", + "default": false + }, + "verify_enabled": { + "type": "boolean", + "description": "Allow/disallow TOTP verification for users.", + "default": false + } + }, + "additionalProperties": true + }, + "phone": { + "type": "object", + "properties": { + "enroll_enabled": { + "type": "boolean", + "description": "Allow/disallow phone enrollment for users.", + "default": false + }, + "verify_enabled": { + "type": "boolean", + "description": "Allow/disallow phone verification for users.", + "default": false + }, + "otp_length": { + "type": "number", + "description": "The length of the OTP code.", + "default": 6 + }, + "template": { + "type": "string", + "description": "The template to use for the phone message.", + "default": "Your code is {{ .Code }}" + }, + "max_frequency": { + "type": "string", + "description": "The maximum frequency of the phone messages.", + "default": "5s" + } + }, + "additionalProperties": true + }, + "web_authn": { + "type": "object", + "properties": { + "enroll_enabled": { + "type": "boolean", + "description": "Allow/disallow WebAuthn enrollment for users.", + "default": false + }, + "verify_enabled": { + "type": "boolean", + "description": "Allow/disallow WebAuthn verification for users.", + "default": false + } + }, + "additionalProperties": true + }, + "max_enrolled_factors": { + "type": "number", + "description": "The maximum number of MFA factors a user can enroll in.", + "default": 10 + } + }, + "additionalProperties": true + }, + "sessions": { + "type": "object", + "properties": { + "timebox": { + "type": "string", + "description": "The timebox for the user session." + }, + "inactivity_timeout": { + "type": "string", + "description": "The inactivity timeout for the user session." + } + }, + "additionalProperties": true, + "default": {} + }, + "email": { + "type": "object", + "properties": { + "enable_signup": { + "type": "boolean", + "description": "Allow/disallow new user signups via email to your project.", + "default": true + }, + "double_confirm_changes": { + "type": "boolean", + "description": "If enabled, a user will be required to confirm any email change on both the old and new email addresses.", + "default": true + }, + "enable_confirmations": { + "type": "boolean", + "description": "If enabled, users need to confirm their email address before signing in.", + "default": false + }, + "secure_password_change": { + "type": "boolean", + "description": "If enabled, users will need to reauthenticate or have logged in recently to change their password.", + "default": false + }, + "max_frequency": { + "type": "string", + "description": "Controls the minimum amount of time that must pass before sending another signup confirmation or password reset email.", + "default": "1s" + }, + "otp_length": { + "type": "number", + "description": "Number of characters used in the email OTP.", + "default": 6 + }, + "otp_expiry": { + "type": "number", + "description": "Number of seconds before the email OTP expires.", + "default": 3600 + }, + "smtp": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable SMTP for email delivery.", + "default": false + }, + "host": { + "type": "string", + "description": "Hostname or IP address of the SMTP server." + }, + "port": { + "type": "number", + "description": "Port number of the SMTP server." + }, + "user": { + "type": "string", + "description": "Username for authenticating with the SMTP server." + }, + "admin_email": { + "type": "string", + "description": "Email used as the sender for emails sent from the application." + }, + "sender_name": { + "type": "string", + "description": "Display name used as the sender for emails sent from the application." + } + }, + "additionalProperties": true + }, + "template": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "subject": { + "type": "string", + "description": "Subject line for the email template.", + "default": "" + }, + "content_path": { + "type": "string", + "description": "Path to the HTML template.", + "default": "" + } + }, + "additionalProperties": true + }, + "description": "Custom email template configuration.", + "default": {} + }, + "notification": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the notification email.", + "default": false + }, + "subject": { + "type": "string", + "description": "Subject line for the notification email.", + "default": "" + }, + "content_path": { + "type": "string", + "description": "Path to the HTML notification template.", + "default": "" + } + }, + "additionalProperties": true + }, + "description": "Notification email configuration.", + "default": {} + } + }, + "additionalProperties": true + }, + "sms": { + "type": "object", + "properties": { + "enable_signup": { + "type": "boolean", + "description": "Allow/disallow new user signups via SMS to your project.", + "default": false + }, + "enable_confirmations": { + "type": "boolean", + "description": "If enabled, users need to confirm their phone number before signing in.", + "default": false + }, + "template": { + "type": "string", + "description": "The template to use for the SMS message.", + "default": "Your code is {{ .Code }}" + }, + "max_frequency": { + "type": "string", + "description": "Controls the minimum amount of time that must pass before sending another sms otp.", + "default": "5s" + }, + "twilio": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable Twilio provider for phone login.", + "default": false + }, + "account_sid": { + "type": "string", + "description": "The account SID for the Twilio API.", + "default": "" + }, + "message_service_sid": { + "type": "string", + "description": "The message service SID for the Twilio API.", + "default": "" + } + }, + "additionalProperties": true + }, + "twilio_verify": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable Twilio Verify provider for phone verification.", + "default": false + }, + "account_sid": { + "type": "string", + "description": "The account SID for the Twilio API." + }, + "message_service_sid": { + "type": "string", + "description": "The message service SID for the Twilio API." + } + }, + "additionalProperties": true + }, + "messagebird": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable MessageBird provider for phone login.", + "default": false + }, + "originator": { + "type": "string", + "description": "The originator of the SMS message." + } + }, + "additionalProperties": true + }, + "textlocal": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable Textlocal provider for phone login.", + "default": false + }, + "sender": { + "type": "string", + "description": "The sender of the SMS message." + } + }, + "additionalProperties": true + }, + "vonage": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable/disable Vonage provider for phone login.", + "default": false + }, + "from": { + "type": "string", + "description": "The sender of the SMS message." + }, + "api_key": { + "type": "string", + "description": "The API key for the Vonage API." + } + }, + "additionalProperties": true + }, + "test_otp": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "description": "Use pre-defined map of phone number to OTP for testing." + } + }, + "additionalProperties": true + }, + "external": { + "type": "object", + "properties": { + "apple": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Apple OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Apple OAuth provider.", + "default": "" + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Apple OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": true + }, + "azure": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Azure OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Azure OAuth provider.", + "default": "" + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Azure OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": true + }, + "bitbucket": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Bitbucket OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Bitbucket OAuth provider.", + "default": "" + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Bitbucket OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": true + }, + "discord": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Discord OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Discord OAuth provider.", + "default": "" + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Discord OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": true + }, + "facebook": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Facebook OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Facebook OAuth provider.", + "default": "" + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Facebook OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": true + }, + "github": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the GitHub OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the GitHub OAuth provider.", + "default": "" + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the GitHub OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": true + }, + "gitlab": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the GitLab OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the GitLab OAuth provider.", + "default": "" + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "https://gitlab.com" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the GitLab OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": true + }, + "google": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Google OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Google OAuth provider.", + "default": "" + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Google OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": true + }, + "kakao": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Kakao OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Kakao OAuth provider.", + "default": "" + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Kakao OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": true + }, + "keycloak": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Keycloak OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Keycloak OAuth provider.", + "default": "" + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "", + "examples": ["https://keycloak.example.com/realms/myrealm"] + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Keycloak OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": true + }, + "linkedin_oidc": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the LinkedIn OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the LinkedIn OAuth provider.", + "default": "" + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the LinkedIn OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": true + }, + "notion": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Notion OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Notion OAuth provider.", + "default": "" + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Notion OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": true + }, + "twitch": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Twitch OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Twitch OAuth provider.", + "default": "" + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Twitch OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": true + }, + "twitter": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Twitter OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Twitter OAuth provider.", + "default": "" + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Twitter OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": true + }, + "x": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the X OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the X OAuth provider.", + "default": "" + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the X OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": true + }, + "slack_oidc": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Slack OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Slack OAuth provider.", + "default": "" + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Slack OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": true + }, + "spotify": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Spotify OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Spotify OAuth provider.", + "default": "" + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Spotify OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": true + }, + "workos": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the WorkOS OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the WorkOS OAuth provider.", + "default": "" + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the WorkOS OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": true + }, + "zoom": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use the Zoom OAuth provider.", + "default": false + }, + "client_id": { + "type": "string", + "description": "Client ID for the Zoom OAuth provider.", + "default": "" + }, + "url": { + "type": "string", + "description": "The base URL used for constructing the URLs to request authorization and access tokens.", + "default": "" + }, + "redirect_uri": { + "type": "string", + "description": "The URI the Zoom OAuth2 provider will redirect to with the code and state values.", + "default": "" + }, + "skip_nonce_check": { + "type": "boolean", + "description": "If true, the nonce check will be skipped.", + "default": false + }, + "email_optional": { + "type": "boolean", + "description": "If true, authentication succeeds when the provider does not return an email address.", + "default": false + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "web3": { + "type": "object", + "properties": { + "solana": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this Web3 provider.", + "default": false + } + }, + "additionalProperties": true + }, + "ethereum": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this Web3 provider.", + "default": false + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "oauth_server": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable OAuth server functionality.", + "default": false + }, + "authorization_url_path": { + "type": "string", + "description": "Path for OAuth consent flow UI.", + "default": "/oauth/consent" + }, + "allow_dynamic_registration": { + "type": "boolean", + "description": "Allow dynamic client registration.", + "default": false + } + }, + "additionalProperties": true + }, + "third_party": { + "type": "object", + "properties": { + "firebase": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this third-party auth provider.", + "default": false + }, + "project_id": { + "type": "string", + "description": "Firebase project ID." + } + }, + "additionalProperties": true + }, + "auth0": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this third-party auth provider.", + "default": false + }, + "tenant": { + "type": "string", + "description": "Auth0 tenant." + }, + "tenant_region": { + "type": "string", + "description": "Auth0 tenant region." + } + }, + "additionalProperties": true + }, + "aws_cognito": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this third-party auth provider.", + "default": false + }, + "user_pool_id": { + "type": "string", + "description": "AWS Cognito user pool ID." + }, + "user_pool_region": { + "type": "string", + "description": "AWS Cognito user pool region." + } + }, + "additionalProperties": true + }, + "clerk": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this third-party auth provider.", + "default": false + }, + "domain": { + "type": "string", + "description": "Clerk domain." + } + }, + "additionalProperties": true + }, + "workos": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable this third-party auth provider.", + "default": false + }, + "issuer_url": { + "type": "string", + "description": "WorkOS issuer URL." + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "db": { + "type": "object", + "properties": { + "port": { + "type": "number", + "description": "Port to use for the local database URL.", + "default": 54322 + }, + "shadow_port": { + "type": "number", + "description": "Port used by db diff command to initialize the shadow database.", + "default": 54320 + }, + "health_timeout": { + "type": "string", + "description": "Maximum amount of time to wait for health check when starting the local database.", + "default": "2m" + }, + "major_version": { + "type": "number", + "description": "The database major version to use. This has to be the same as your remote database's.", + "default": 17 + }, + "pooler": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the local PgBouncer service.", + "default": false + }, + "port": { + "type": "number", + "description": "Port to use for the local connection pooler.", + "default": 54329 + }, + "pool_mode": { + "type": "string", + "enum": ["transaction", "session"], + "description": "Specifies when a server connection can be reused by other clients.", + "default": "transaction" + }, + "default_pool_size": { + "type": "number", + "description": "How many server connections to allow per user/database pair.", + "default": 20 + }, + "max_client_conn": { + "type": "number", + "description": "Maximum number of client connections allowed.", + "default": 100 + } + }, + "additionalProperties": true + }, + "migrations": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "If disabled, migrations will be skipped during a db push or reset.", + "default": true + }, + "schema_paths": { + "type": "array", + "items": { + "type": "string", + "description": "Schema file path, directory, or glob relative to the supabase directory." + }, + "description": "Ordered list of schema files, directories, or glob patterns that describe your database.", + "default": [] + } + }, + "additionalProperties": true + }, + "seed": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable seeding the database with SQL files.", + "default": true + }, + "sql_paths": { + "type": "array", + "items": { + "type": "string", + "description": "Path to a SQL file used to seed the database." + }, + "description": "Ordered list of seed files to load during db reset.", + "default": ["./seed.sql"] + } + }, + "additionalProperties": true + }, + "settings": { + "type": "object", + "properties": { + "effective_cache_size": { + "type": "string" + }, + "logical_decoding_work_mem": { + "type": "string" + }, + "maintenance_work_mem": { + "type": "string" + }, + "max_connections": { + "type": "number" + }, + "max_locks_per_transaction": { + "type": "number" + }, + "max_parallel_maintenance_workers": { + "type": "number" + }, + "max_parallel_workers": { + "type": "number" + }, + "max_parallel_workers_per_gather": { + "type": "number" + }, + "max_replication_slots": { + "type": "number" + }, + "max_slot_wal_keep_size": { + "type": "string" + }, + "max_standby_archive_delay": { + "type": "string" + }, + "max_standby_streaming_delay": { + "type": "string" + }, + "max_wal_size": { + "type": "string" + }, + "max_wal_senders": { + "type": "number" + }, + "max_worker_processes": { + "type": "number" + }, + "session_replication_role": { + "type": "string", + "enum": ["origin", "replica", "local"], + "description": "Session replication role." + }, + "shared_buffers": { + "type": "string" + }, + "statement_timeout": { + "type": "string" + }, + "track_activity_query_size": { + "type": "string" + }, + "track_commit_timestamp": { + "type": "boolean" + }, + "wal_keep_size": { + "type": "string" + }, + "wal_sender_timeout": { + "type": "string" + }, + "work_mem": { + "type": "string" + } + }, + "additionalProperties": true + }, + "network_restrictions": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable management of network restrictions.", + "default": false + }, + "allowed_cidrs": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Allowed IPv4 CIDR blocks.", + "default": ["0.0.0.0/0"] + }, + "allowed_cidrs_v6": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Allowed IPv6 CIDR blocks.", + "default": ["::/0"] + } + }, + "additionalProperties": true + }, + "ssl_enforcement": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Reject non-secure connections to the database.", + "default": false + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "realtime": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the local Realtime service.", + "default": true + }, + "ip_version": { + "type": "string", + "enum": ["IPv4", "IPv6"], + "description": "Bind realtime via either IPv4 or IPv6.", + "default": "IPv4" + }, + "max_header_length": { + "type": "number", + "description": "Maximum length of the HTTP header.", + "default": 4096 + } + }, + "additionalProperties": true + }, + "storage": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable the local Storage service.", + "default": true + }, + "file_size_limit": { + "type": "string", + "description": "The maximum file size allowed.", + "default": "50MiB", + "examples": ["5MB", "500KB"] + }, + "image_transformation": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable image transformation.", + "default": false + } + }, + "additionalProperties": true + }, + "buckets": { + "type": "object", + "additionalProperties": { + "type": "object", + "properties": { + "public": { + "type": "boolean", + "description": "Enable public access to the bucket.", + "default": false + }, + "file_size_limit": { + "type": "string", + "description": "The maximum file size allowed for the bucket.", + "default": "50MiB", + "examples": ["5MB", "500KB"] + }, + "allowed_mime_types": { + "type": "array", + "items": { + "type": "string", + "description": "A MIME type allowed for the bucket." + }, + "description": "The list of allowed MIME types for the bucket.", + "default": [] + }, + "objects_path": { + "type": "string", + "description": "The path to the objects in the bucket.", + "default": "" + } + }, + "additionalProperties": true + }, + "description": "Storage buckets configuration." + }, + "s3_protocol": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Allow connections via S3 compatible clients.", + "default": true + } + }, + "additionalProperties": true + }, + "analytics": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable analytics buckets.", + "default": false + }, + "max_namespaces": { + "type": "number", + "description": "Maximum number of analytics namespaces.", + "default": 5 + }, + "max_tables": { + "type": "number", + "description": "Maximum number of analytics tables.", + "default": 10 + }, + "max_catalogs": { + "type": "number", + "description": "Maximum number of analytics catalogs.", + "default": 2 + }, + "buckets": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + }, + "description": "Analytics bucket configuration.", + "default": {} + } + }, + "additionalProperties": true + }, + "vector": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable vector buckets.", + "default": true + }, + "max_buckets": { + "type": "number", + "description": "Maximum number of vector buckets.", + "default": 10 + }, + "max_indexes": { + "type": "number", + "description": "Maximum number of vector indexes.", + "default": 5 + }, + "buckets": { + "type": "object", + "additionalProperties": { + "anyOf": [ + { + "type": "object" + }, + { + "type": "array" + } + ] + }, + "description": "Vector bucket configuration.", + "default": {} + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + }, + "workers": { + "type": "object", + "patternProperties": { + "^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$": { + "type": "object", + "properties": { + "runtime": { + "type": "string", + "description": "Runtime the worker is built on: `dockerfile` to build the directory's own\nDockerfile, or one of the catalog runtimes (`node`, `deno`). Guessed from\nmarker files when unset.", + "examples": ["node"] + }, + "size": { + "type": "string", + "description": "Instance size, denominated by memory. Each size implies its own vCPU count,\nso it is the one dial rather than two.", + "examples": ["2gb"] + }, + "instances": { + "type": "integer", + "minimum": 0, + "description": "Number of instances to run. Every deploy sends a complete spec, so a count\nrecorded here is what keeps a scaled worker scaled; `--instances` overrides\nit for one deploy. Defaults to 1.", + "examples": [3] + }, + "source": { + "type": "string", + "description": "Directory holding the worker's code, relative to the project root, when it\ndoes not live at `supabase/workers//`.", + "examples": ["packages/api"] + } + }, + "additionalProperties": true + } + }, + "description": "Worker-specific configuration keyed by worker name.", + "default": {} + }, + "experimental": { + "type": "object", + "properties": { + "orioledb_version": { + "type": "string", + "description": "Postgres storage engine version for OrioleDB." + }, + "s3_host": { + "type": "string", + "description": "S3 bucket URL.", + "examples": [".s3-.amazonaws.com", "env(S3_HOST)"] + }, + "s3_region": { + "type": "string", + "description": "S3 bucket region.", + "examples": ["us-east-1", "env(S3_REGION)"] + }, + "webhooks": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Enable experimental webhooks.", + "default": false + } + }, + "additionalProperties": true + }, + "pgdelta": { + "type": "object", + "properties": { + "enabled": { + "type": "boolean", + "description": "Use pg-delta as the schema diff engine for db diff / db pull / db remote commit. Set false to fall back to the legacy migra engine.", + "default": false + }, + "declarative_schema_path": { + "type": "string", + "description": "Directory under supabase/ where declarative schema files are written.", + "examples": ["./schemas"] + }, + "format_options": { + "type": "string", + "description": "JSON string passed through to pg-delta SQL formatting.", + "examples": [ + "{\"keywordCase\":\"upper\",\"indent\":2,\"maxWidth\":80,\"commaStyle\":\"trailing\"}" + ] + } + }, + "additionalProperties": true + }, + "inspect": { + "type": "object", + "properties": { + "rules": { + "type": "array", + "items": { + "type": "object", + "properties": { + "query": { + "type": "string", + "description": "Inspection query." + }, + "name": { + "type": "string", + "description": "Inspection rule name." + }, + "pass": { + "type": "string", + "description": "Success message." + }, + "fail": { + "type": "string", + "description": "Failure message." + } + }, + "additionalProperties": true + }, + "description": "Inspection rules.", + "default": [] + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true + } + }, + "additionalProperties": true +} diff --git a/docs/adr/0020-config-naming-vocabulary.md b/docs/adr/0020-config-naming-vocabulary.md index d505ec114d..2a1dfc84ce 100644 --- a/docs/adr/0020-config-naming-vocabulary.md +++ b/docs/adr/0020-config-naming-vocabulary.md @@ -44,9 +44,9 @@ and its CLI consumer: Prefix rule: `Cli*` names the local checkout side — what the CLI reads, writes, or resolves about itself on disk. A bare `Project*` name is reserved for the hosted Supabase project. Helpers that operate on config values follow the config family regardless of their inputs, not the shape of -whatever they're passed — `resolveCliConfigValue` and `MissingCliConfigValueError` are `Cli*`-named -even though both operate on plain config values, because the config they resolve or complain about -is the local-checkout document. +whatever they're passed — `resolveCliConfigValue` and `CliConfigParseError` are `Cli*`-named even +though one resolves a config value and the other reports a parse failure, because in both cases the +config in question is the local-checkout document. This convention is documented normatively in three places, so it is available wherever a session — human or agent — starts working in this repo: diff --git a/knip.json b/knip.json index 0dd065b891..43305fb407 100644 --- a/knip.json +++ b/knip.json @@ -3,7 +3,7 @@ "exclude": ["catalogReferences"], "workspaces": { ".": { - "entry": [".github/scripts/**/*.ts", "tools/release/*.ts"], + "entry": [".github/scripts/**/*.ts", "tools/*.ts", "tools/release/*.ts"], "ignore": [".repos/**", "apps/cli-go/**"], "ignoreBinaries": ["go"], "ignoreDependencies": ["verdaccio"] @@ -34,6 +34,9 @@ "entry": ["src/**/*.test.ts"], "ignoreDependencies": ["undici"] }, + "packages/config": { + "entry": ["src/**/*.test.ts"] + }, "packages/process-compose": { "entry": ["src/**/*.test.ts", "tests/**/*.ts"] }, diff --git a/package.json b/package.json index 8fdbb1c711..5e98cf50dc 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "fmt:fix": "oxfmt --config .oxfmtrc.json", "knip:check": "knip-bun", "knip:fix": "knip-bun --fix", + "check:config-api": "bun tools/config-api-compare.ts", "repos:install": "git submodule update --init --recursive", "repos:pull": "git submodule update --remote", "local-registry": "bun tools/release/local-registry.ts", diff --git a/packages/config/.gitignore b/packages/config/.gitignore new file mode 100644 index 0000000000..6f4e986fa0 --- /dev/null +++ b/packages/config/.gitignore @@ -0,0 +1,4 @@ +# Scratch tree `tools/config-api-compare.ts` (repo root) extracts the PR base +# revision's package source into, so tsc resolves dependencies by walking up +# to this package's own node_modules. Cleaned up on exit; never committed. +.api-compare/ diff --git a/packages/config/.npmignore b/packages/config/.npmignore new file mode 100644 index 0000000000..769812e808 --- /dev/null +++ b/packages/config/.npmignore @@ -0,0 +1,11 @@ +# Without this file, `npm pack`/`npm publish` fall back to the root +# `.gitignore` for this whole directory — and its bare `dist` line prunes +# `packages/config/dist/` from npm's packlist WALK before `package.json`'s +# `files` array is ever consulted, silently shipping a tarball with zero +# `dist/**` files despite `dist` being explicitly listed there (CLI-2234). +# This file's mere presence is what fixes it: once an `.npmignore` exists, +# npm uses it instead of consulting `.gitignore`. `files` in package.json +# still governs what actually ships; the one line below just restates, for +# npm's own ignore pass, the same test-file exclusion `files`'s +# `!src/**/*.test.ts` negation already expresses. +*.test.ts diff --git a/packages/config/AGENTS.md b/packages/config/AGENTS.md index 4556305cc4..4ea94eb8b2 100644 --- a/packages/config/AGENTS.md +++ b/packages/config/AGENTS.md @@ -5,21 +5,41 @@ Supabase project configuration package built on Effect V4 Schema — owns the ca ## Entrypoints -Three entrypoints plus a generated artifact (see ADR 0009's 2026-08-24 decision for the full -rationale): +Six supported import paths total (see ADR 0009's 2026-08-24 decision for the full rationale): four +module entrypoints (`.`, `./io`, `./effect`, `./internal`) plus two generated JSON Schema +artifacts (`./schema.json`, `./project-schema.json`). -- `@supabase/config` (`.`) — pure, browser/edge-safe surface. The `CliConfigSchema` and - derived types, config encoding, sparse-config defaults, and error classes. No file IO, no - Effect-returning function, no `@effect/platform-*`/`node:`/`bun:` module anywhere in its - transitive import graph. +- `@supabase/config` (`.`) — pure, browser/edge-safe surface. `CliConfigSchema`/`ProjectConfigSchema` + and their derived types, config encoding, sparse-config defaults, the `ProjectConfig` converters + (`toProjectConfig`, `fromConfigDocument`, `fromApiProjectConfig`, …), and error classes. No file + IO, no Effect-returning function, no `@effect/platform-*`/`node:`/`bun:` module anywhere in its + transitive import graph. `fromConfigDocument` also accepts a `CliConfigWithRawPresence` pair (a + `CliConfig` alongside which keys were actually present in the source document) — presence matters + because the schema defaults every optional section, so the decoded `CliConfig` alone can't tell + "explicitly set to the default" from "never set" (ADR 0021). Call `unmappedApiFields` after + `fromApiProjectConfig` if you care whether this package version understood the response. - `@supabase/config/io` — a Promise-based file-IO facade for **external, non-Effect Node/Bun consumers only**. Resolved via package.json exports conditions (`bun`/`node`/`browser`/`default`). Has zero internal consumers by design — nothing inside this monorepo should import it. - `@supabase/config/effect` — the Effect-native superset. Re-exports everything from `.` plus the Effect-returning config-loading/saving programs, `CliConfigStore`/`cliConfigStoreLayer`, - project-environment resolution, and functions-manifest inference. -- `@supabase/config/schema.json` — generated JSON Schema for `CliConfig` (a `dist/` build - output). + project-environment resolution, and `inferFunctionsManifest` (discovers and validates + `supabase/functions/*` on disk). +- `@supabase/config/internal` (CLI-2234) — NOT covered by semver, and only `apps/cli` may import it + (enforced by `src/monorepo-import-contract.unit.test.ts`). Exists solely for `apps/cli`'s own + Go-parity call sites and contract-guard tests: `loadCliConfig`/`resolveCliConfigValue`/ + `resolveCliConfigSubtree` — the SAME runtime functions `./effect` exports, re-typed here to + additionally accept the internal-only `goViperCompat` option (`InternalLoadCliConfigOptions` for + `loadCliConfig`; `resolveCliConfigValue`/`resolveCliConfigSubtree`'s own widened options type, + `InternalResolveCliConfigOptions`, is package-internal and not itself re-exported) — plus the + otherwise-internal registry data (`AUTH_HOOK_NAMES`, `unmappedSecretApiPaths`, + `projectConfigMappingRows`, `ProjectConfigMappingRow`, `ProjectConfigApiAttributes`, + `ENV_CAPTURE_REGEX`). Anything here can change or vanish in any release. +- `@supabase/config/schema.json` — generated JSON Schema (draft 2020-12) for `CliConfig` (a + `dist/` build output). +- `@supabase/config/project-schema.json` (CLI-2234) — generated JSON Schema (draft 2020-12) for + `ProjectConfig`, derived from `ProjectConfigSchema` (`src/project-config/project-schema.ts`); a + `dist/` build output alongside `schema.json`. ## Monorepo import rule @@ -31,7 +51,12 @@ rationale): from `@supabase/config`. - `@supabase/config/io` is exclusively for external consumers outside this monorepo that aren't Effect-native. Do not add an internal consumer of it. -- Never deep-import this package's internals (e.g. `@supabase/config/src/io.ts`). Only the four +- `@supabase/config/internal` is for `apps/cli`'s own Go-parity call sites and contract-guard + tests only — a symbol that needs the internal-only `goViperCompat` typings, or the internal + registry data, imports it from there; every other symbol in the same import statement stays on + its public specifier (`.`/`./effect`). Enforced: every `@supabase/config/internal` occurrence + outside this package must be under `apps/cli/`. +- Never deep-import this package's internals (e.g. `@supabase/config/src/io.ts`). Only the six entrypoints above are supported import paths. ## Pure-graph invariant @@ -45,7 +70,65 @@ import graph against a hardcoded allowlist, pins both entrypoints' exact runtime asserts the package.json `exports` map shape. Any change that grows the pure graph or the export surface must update that test deliberately — it is not meant to be a silent pass. +## Build (CLI-2232) + +`pnpm --filter @supabase/config build` (or `pnpm run build` from this package) runs +`scripts/build.ts`, in order: + +1. Removes any stale `dist/` (a rename that leaves an orphaned compiled module behind must not + ship), then compiles `src/` to `dist/` (`tsc -p tsconfig.build.json`) — the `.js`/`.d.ts` output + every `dist`/`types`/`default` export condition points at. +2. Renders both generated JSON Schema artifacts (`dist/schema.json`, `dist/project-schema.json`) + from `toCliConfigJsonSchema()`/`toProjectConfigJsonSchema()`, post-processed (via + `scripts/json-schema-postprocess.ts`) to collapse Effect's non-finite-number `anyOf` encoding + back to a plain `number`/`integer` node and to add `$id`/`title`/`description`, then formatted + through `oxfmt`. +3. Verifies every `types`/non-`bun` `default` target (plus both JSON artifacts) declared in + package.json's `exports` map actually exists on disk. +4. Runs a tree-shake probe: bundles a probe importing only `CliConfigSchema` from the compiled + `dist/index.js` for a `browser` target and asserts the output excludes registry-only code, + proving the package.json `sideEffects: false` claim against real compiled output rather than + merely asserting it — plus a positive-control probe (bundling `projectConfigMappingRows` from + `dist/internal.js`) proving the registry-only marker is actually detectable by this bundling + method before trusting its absence elsewhere as meaningful. +5. Runs a pack-and-install smoke test: `npm pack`s the real publish tarball (governed by `files`/ + `.npmignore` — the exact thing `npm publish` would ship), extracts it into a fresh, isolated + consumer project, symlinks in the real, already pnpm-resolved runtime deps (network-free), and + imports every entrypoint and JSON artifact through a real `node` process — catching `files`/ + `exports` drift a workspace-link smoke test or a `tsc`-only build would miss entirely. + +`dist/` is gitignored and rebuilt on demand — no build output is checked in. The public type +surface is instead enforced per-PR by export snapshots and purity walkers (see "Testing" below) +plus the repo-root `pnpm check:config-api` (`tools/config-api-compare.ts`), which diffs this +package's declaration output between the PR base and head commits and is advisory at PR time. A +release-time tarball diff is planned under CLI-2233 as the hard gate. + +### Publishing the tarball (CLI-2234) + +A `.npmignore` file exists at this package's root — even though its own rules exclude almost +nothing `files` in package.json doesn't already exclude — because npm's packlist walk otherwise +falls back to the ROOT `.gitignore` for this whole directory, and that file's bare `dist` line +prunes `packages/config/dist/` from the walk entirely before `files` is ever consulted, silently +shipping a tarball with zero `dist/**` files. An `.npmignore`'s mere presence (regardless of +content) stops npm from consulting `.gitignore` at all; `files` still governs what actually ships. +Verify `npm pack --dry-run` and `pnpm pack --dry-run` produce equivalent content after touching +either file. + ## Testing Run tests from this package with `bun --bun vitest run --project unit` (plain `node` vitest is -broken here). Always run the relevant unit tests for what you changed before considering a task done. +broken here). Always run the relevant unit tests for what you changed before considering a task +done. Besides ordinary behavioral coverage, the following contract tests enforce this package's +own guarantees and must stay green after any entrypoint or type-surface change: + +- `src/entrypoint-purity.unit.test.ts` — the pure-graph invariant above (also walked separately for + `src/io-browser.ts`, the `browser` condition target for `./io`), plus pinned export-name + snapshots for `.`/`./effect`/`./internal` and the package.json `exports` map shape. +- `src/monorepo-import-contract.unit.test.ts` — the "Monorepo import rule" above: no internal + `./io` consumer, no deep `@supabase/config/src/*` import, and no `@supabase/config/internal` + import outside `apps/cli/` — scanning `apps/` and `packages/` while excluding this package's own + directory. +- `src/lib/resolve.unit.test.ts` — behavioral coverage of the public sync resolvers. +- `scripts/json-schema-postprocess.unit.test.ts` / `scripts/build-artifacts.unit.test.ts` — the + JSON Schema post-processing `renderJsonSchema` applies (non-finite-number `anyOf` collapse, + `$id`/`title`/`description`), the second against the real generated documents. diff --git a/packages/config/LICENSE b/packages/config/LICENSE new file mode 100644 index 0000000000..f1802dffa8 --- /dev/null +++ b/packages/config/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 Supabase, Inc. and contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/config/README.md b/packages/config/README.md index e48a9817cf..22fac21442 100644 --- a/packages/config/README.md +++ b/packages/config/README.md @@ -1,144 +1,335 @@ # @supabase/config -Supabase project configuration package built on Effect V4 Schema — owns the canonical `CliConfig` -document schema, config file loading/saving, and JSON Schema generation. +Supabase project configuration package built on Effect V4 Schema — the config-file document model +(`CliConfig`), the hosted-project subset (`ProjectConfig`), schema-backed parsing, validation, and +encoding, defaults and sparse-diff helpers, and the converters between the local and hosted +representations. It owns: -- the canonical `CliConfig` schema -- the `CliConfigStore` Effect service for config IO -- JSON Schema generation at `@supabase/config/schema.json` -- config file loading/saving for `supabase/config.json` -- backward-compatible TOML support for `supabase/config.toml` +- the canonical `CliConfig` schema for `supabase/config.toml`/`supabase/config.json` +- the `CliConfigStore` Effect service for config file IO, and a Promise-based facade over it +- the `ProjectConfig` hosted-project subset and its converters to/from a `CliConfig` document or a + Management API v2 project-config response +- `ProjectConfigSchema`, a runtime-validating companion to `ProjectConfig` +- JSON Schema generation for both shapes, at `@supabase/config/schema.json` and + `@supabase/config/project-schema.json` ## Naming - `CliConfig` — the config _document_ (`supabase/config.toml`/`.json`) — the full local superset the CLI reads and writes. -- `ProjectConfig` — the hosted-project subset: a sparse overlay of the hosted sections (api, auth, - db, realtime, storage, workers, experimental) describing what a Supabase project looks like on - the platform. Introduced by CLI-2230: produced by `toProjectConfig` from either a `CliConfig` - document or a Management API response — see "ProjectConfig mapping" below. +- `ProjectConfig` — the hosted-project subset: a sparse overlay of the hosted sections (`api`, + `auth`, `db`, `realtime`, `storage`, `workers`, `experimental`) describing what a Supabase + project looks like on the platform. Produced by `toProjectConfig` from either a `CliConfig` + document or a Management API response — see "ProjectConfig: producing and validating values" + below. - `CliSettings` — the CLI's own runtime settings; lives in `apps/cli`, not this package. Use the `Cli*` prefix for the local checkout side and a bare `Project*` name for the hosted -Supabase project. Helpers that operate on config values follow the config family regardless of -their inputs (`resolveCliConfigValue`, `MissingCliConfigValueError`). See -[ADR 0020](../../docs/adr/0020-config-naming-vocabulary.md) for the full decision record. +Supabase project. Config-value helpers follow the config family regardless of their inputs +(`resolveCliConfigValue`). See +[ADR 0020](https://github.com/supabase/cli/blob/develop/docs/adr/0020-config-naming-vocabulary.md) +and [docs/cli-config-loading.md](./docs/cli-config-loading.md) for the full vocabulary. ## Entrypoints -- `@supabase/config` — pure, browser/edge-safe surface: the `CliConfig` schema and types, - config encoding, sparse-config defaults, and errors. No file IO, no Effect-returning functions. -- `@supabase/config/io` — Promise-based file-IO facade for non-Effect consumers. The bun/node - implementation is picked automatically via package.json exports conditions. Requires installing - exactly one of the optional platform peers — `@effect/platform-bun` under Bun, `@effect/platform-node` - under Node — and is unavailable in browser bundles (the `browser` condition resolves to a stub that - throws); use `@supabase/config` there instead. -- `@supabase/config/effect` — Effect-native superset of `@supabase/config`, adding the - `CliConfigStore` service, `cliConfigStoreLayer`, and other Effect programs (config - loading/saving, project env resolution, functions manifest inference). -- `@supabase/config/schema.json` — generated JSON Schema for `CliConfig`. - -## ProjectConfig mapping - -The hosted-project subset — `ProjectConfig` — and its normalizers live on the pure entrypoint -(`@supabase/config`), so the CLI and Studio share one implementation: - -- `toProjectConfig(source)` — thin dispatcher over the two normalizers; pass `{ cliConfig }` - or `{ apiResponse }`. Throws `ProjectConfigParseError` when `source` carries neither own key - or both. -- `fromConfigDocument(cliConfig)` — projection of a `CliConfig` document (or any - `EffectiveConfig`): keeps the hosted sections (`api`, `auth`, `db`, `realtime`, `storage`, - `workers`, `experimental`), drops local-only ones. Hosted sections are copied at field - granularity, omitting every `x-secret` leaf, and every duration/byte-size field a mapping - row canonicalizes (e.g. a document's `"24h"` becomes `"24h0m0s"`, matching what the API side - would emit for the same logical value) — parity with `fromApiProjectConfig`'s own secret - omission and canonical spellings. **Not a verbatim rendering of the document**: per - [ADR 0021](../../docs/adr/0021-projectconfig-convergence-semantics.md), the result also - applies SMS-provider push precedence and disabled-sentinel pruning, so it predicts what the - hosted config will look like _after_ pushing the document, not the document's own declared - values. **RECOMMENDED for a file-sourced config**: pass `{ config, document }` instead of a - bare `cliConfig` whenever a raw `document` is available (`LoadedCliConfig`'s own shape — - `@supabase/config/io`'s loaders return one, and it is structurally assignable here without a - cast). With `document`, the projection additionally mirrors the legacy push pipeline's own - raw-presence gates (a raw-absent `auth.captcha`, an un-raw-declared external provider, …), which - a bare `cliConfig` operand cannot — see ADR 0021's "Limits" section for exactly which fields - this closes and which residual gap remains even with `document` supplied. `@supabase/config/io`'s - `loadCliConfig` supplies a `document`; `saveCliConfig`'s returned `LoadedCliConfig` does NOT - (there is no raw file being re-read on a save), so passing that result straight into - `fromConfigDocument` silently falls back to the un-remedied, bare-`cliConfig` behavior. -- `fromApiProjectConfig(input)` — translation of a Management API v2 project-config response - (the full envelope, its `data` object, or bare `data.attributes`): registry-driven renames, boolean inversions, and - unit conversions; lenient toward API keys this package version doesn't know; secret fields - omitted (the API reports HMAC digests, never plaintext). Attaches a deep-cloned, deep-frozen - copy of the raw attributes as a non-enumerable `_apiResponse` — invisible to encodes and - structural walks, never persisted (ADR 0019). Also not a byte-for-byte echo of the response - (ADR 0021): a `null` on a gating boolean canonicalizes to `enabled: false`, and the same - disabled-sentinel pruning `fromConfigDocument` applies runs here too. Both normalizers throw - `ProjectConfigParseError` on malformed API input (a bad envelope, a mapped field of the wrong - type, or an unparseable schema-decode failure). -- `unmappedApiFields(projectConfig)` — the API fields this package version doesn't map, - derived from the same mapping registry. +| Entrypoint | Contents | Constraints | +| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `.` | `CliConfigSchema`/`ProjectConfigSchema` and their types, config encoding, sparse-config defaults, the `ProjectConfig` converters, error classes | Pure — browser/edge/Node/Bun-safe. No file IO, no Effect-returning function, no `@effect/platform-*`/`node:`/`bun:` module anywhere in its transitive import graph | +| `./io` | A Promise-based facade over the same file-IO/Effect programs as `./effect` | Resolved automatically via package.json export conditions (`bun`/`node`/`browser`/`default`). Requires one of the optional platform peers — `@effect/platform-bun` under Bun, `@effect/platform-node` under Node — installed at runtime; see "Installing" below for the failure mode when it's missing | +| `./effect` | Effect-native superset of `.`: `CliConfigStore`/`cliConfigStoreLayer`, config loading/saving, project-environment resolution, functions-manifest inference | Requires `effect`; requires a platform peer only for the file-IO programs, exactly like `./io` | +| `./internal` | `ENV_CAPTURE_REGEX`, `AUTH_HOOK_NAMES`, `unmappedSecretApiPaths`, `projectConfigMappingRows`, the `ProjectConfigMappingRow`/`ProjectConfigApiAttributes`/`InternalLoadCliConfigOptions` types, plus `loadCliConfig`/`resolveCliConfigValue`/`resolveCliConfigSubtree` re-typed to additionally accept the internal-only `goViperCompat` option — the SAME runtime functions `./effect` exports, not independent implementations | **Not covered by semver, and only `apps/cli` may import it** (enforced by `src/monorepo-import-contract.unit.test.ts`). Exists solely for the Supabase CLI's own use and its contract-guard tests — any export here (its existence, shape, or behavior) can change or vanish in any release without notice | +| `./schema.json` | Generated JSON Schema for `CliConfig` | Draft 2020-12 — a language-agnostic contract for non-TypeScript consumers | +| `./project-schema.json` | Generated JSON Schema for `ProjectConfig` | Draft 2020-12 — a language-agnostic contract for non-TypeScript consumers | + +A few things worth calling out beyond the table: + +- **`./io`'s member names mirror `./effect`'s one-to-one** (`loadCliConfig`, `saveCliConfig`, + `loadCliConfigFile`, `findCliProjectRoot`, `findCliProjectPaths`, `loadCliProjectEnvironment`, + `inferFunctionsManifest`) — the subpath itself conveys Promise-vs-Effect, not the member name. + In a browser bundle, `./io` resolves to a stub whose exports throw a curated error only when + actually invoked (never at import time), directing you back to `.`. +- **`./effect` deliberately shadows two names from `.`.** `resolveCliConfigValue` and + `resolveCliConfigSubtree` exist on both `.` (plain, synchronous) and `./effect` (Effect-typed). + Neither has a failure mode — an unresolved `env(NAME)` reference is preserved verbatim rather + than rejected or thrown. Because explicit named exports win over a star re-export of the same + name, importing from `./effect` always gets you the Effect-typed variant, even though `./effect` + also re-exports everything else from `.` verbatim. +- **`./internal` is genuinely unstable.** It is not merely undocumented — it is explicitly outside + this package's compatibility promise (see "Semver and the published contract" below). + +Bundle size, measured against the full `.` surface: ~390 KB minified (~110 KB minified+gzipped), +most of which is `effect`'s own schema/validation engine — an app that already bundles `effect` +adds closer to ~115 KB minified for this package's own code on top. A consumer that only needs the +shape contract, not runtime validation, can use `@supabase/config/schema.json`/`project-schema.json` +with any JSON Schema validator instead of importing this package at all. + +### Exports at a glance (`.`) + +Every runtime and type export of the pure `.` entrypoint, grouped by category: + +**Schema/types** + +| Export | What it is | +| ------------------------------------------------------------ | ---------------------------------------------------------------------------------------- | +| `CliConfigSchema` | The `CliConfig` Effect schema (decode/encode/validate). | +| `CliConfig` | The decoded `CliConfig` type. | +| `CliConfigJson` | The encoded (pre-decode) `CliConfig` JSON shape. | +| `ConfigFormat` | `"toml" \| "json"`. | +| `LoadedCliConfig` | The shape a successful `loadCliConfig`/`loadCliConfigFile`/`saveCliConfig` call returns. | +| `LoadCliConfigOptions` | Public options accepted by `loadCliConfig`/`loadCliConfigFile`. | +| `CliConfigValueOrigin` / `CliConfigValueSource` | Per-leaf provenance (`"local"`/`"remote"`/`"environment"`). | +| `SaveCliConfigOptions` | Options accepted by `saveCliConfig`. | +| `FunctionsManifest` / `ResolvedFunctionConfig` | The shape `inferFunctionsManifest` (`./effect`/`./io`) returns. | +| `LoadCliProjectEnvironmentOptions` / `CliProjectEnvironment` | Options for, and the merged env-map shape returned by, `loadCliProjectEnvironment`. | +| `CliProjectPaths` | The discovered project paths shape. | +| `ProjectConfig` | The hosted-project subset shape. | +| `ProjectConfigSchema` | Runtime-validating companion to `ProjectConfig` (see below). | +| `CliConfigWithRawPresence` | A `CliConfig` + raw-presence pair `fromConfigDocument` also accepts (ADR 0021). | +| `ReadonlyJsonValue` | A JSON-safe, deeply readonly value type. | +| `ToProjectConfigSource` | `toProjectConfig`'s discriminated `{ cliConfig }`/`{ apiResponse }` input. | + +**Env resolution & value provenance** + +| Export | What it is | +| --------------------------------------------------- | ------------------------------------------------------------------------------- | +| `resolveCliConfigValue` / `resolveCliConfigSubtree` | Resolve/redact `env(NAME)` leaves; plain sync here, Effect-typed on `./effect`. | +| `ResolvedCliConfigValue` | The resolved/redacted shape those two return. | +| `cliConfigValueSourceAt` | Looks up a `LoadedCliConfig.valueOrigins` entry for one path. | + +**Encoding** + +| Export | What it is | +| ------------------------------------------------- | ------------------------------------- | +| `encodeCliConfigToJson` / `encodeCliConfigToToml` | Serialize a `CliConfig` back to text. | + +**Defaults & sparse diff** + +| Export | What it is | +| ----------------------------------------- | --------------------------------------------------- | +| `getDefaultCliConfig` | The schema-derived default `CliConfig`. | +| `omitDefaultValues` / `subtractCliConfig` | Strip-default helpers over an `EffectiveConfig`. | +| `EffectiveConfig` / `SparseCliConfig` | The operand/result types for the two helpers above. | + +**ProjectConfig converters** + +| Export | What it is | +| ---------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| `toProjectConfig` | Dispatcher over the two normalizers below. | +| `fromConfigDocument` | Projects a `CliConfig` (or `CliConfigWithRawPresence` pair) onto `ProjectConfig`. | +| `fromApiProjectConfig` | Translates a Management API v2 project-config response into `ProjectConfig`. | +| `attachApiResponse` | Re-attaches `_apiResponse` after a round-trip that dropped it. | +| `unmappedApiFields` | The API fields this package version doesn't map — call after `fromApiProjectConfig` if you care whether it understood the response. | +| `comparableProjectConfigPaths` / `isComparableProjectConfigPath` | Registry-derived field paths a diff consumer can safely compare. | + +**JSON Schema generators + URLs** + +| Export | What it is | +| ----------------------------------------------------- | --------------------------------------------------------- | +| `toCliConfigJsonSchema` / `toProjectConfigJsonSchema` | Render each shape's JSON Schema (draft 2020-12) document. | +| `CLI_CONFIG_SCHEMA_URL` / `PROJECT_CONFIG_SCHEMA_URL` | The `$id`/`$schema` URL for each generated document. | + +**Errors** + +| Export | What it is | +| --------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `CliConfigParseError` | A malformed `supabase/config.toml`/`.json`. | +| `CliProjectEnvParseError` | A malformed `.env`/`.env.local` file. | +| `DuplicateRemoteProjectIdError` / `InvalidRemoteProjectIdError` | A `[remotes.*]` block problem. | +| `ProjectConfigParseError` | A Management API v2 response, or a caller argument, that failed to map. | + +**Constants** + +| Export | What it is | +| -------------------------------------------------------------------------------------------------- | ---------------------------------------- | +| `edgeFunctionDenoConfigFileName` / `edgeFunctionEntrypointFileName` / `edgeFunctionsDirectoryName` | Edge Functions on-disk layout filenames. | + +## Installing + +This package is not yet published (`private: true`; publishing is tracked separately). Once it +is, install it alongside the peers your runtime needs: + +```sh +npm install @supabase/config effect@rc +``` + +This package requires Effect 4.x, currently only published under the `rc` dist-tag — `effect@latest` +still resolves to 3.x, which will not satisfy this package's peer range. + +`effect` is a required peer dependency. `@effect/platform-bun` and `@effect/platform-node` are +optional peers — install exactly one, matching your runtime, if you use `./io` or `./effect`'s +file-IO programs: + +| Consumer | Required peers | +| ---------------------------------------------- | --------------------------------- | +| Pure / browser / edge (`.` only, no file IO) | `effect` | +| Node (`./io` or `./effect`'s file-IO programs) | `effect`, `@effect/platform-node` | +| Bun (`./io` or `./effect`'s file-IO programs) | `effect`, `@effect/platform-bun` | + +Under the `node`/`bun` export conditions, the matching platform peer is imported eagerly at module +load. A missing peer surfaces as a raw module-resolution error (e.g. `Cannot find package +'@effect/platform-node'`) the first time something imports `./io` or `./effect` — not a curated +message — so install the peer for your runtime before importing either subpath. The `browser` +condition is the one exception: it needs no platform peer, since it resolves to a stub that throws +its own curated error only when invoked (see "Entrypoints" above). + +## ProjectConfig: producing and validating hosted-project values + +The hosted-project subset — `ProjectConfig` — and its converters live on the pure entrypoint +(`.`), so any TypeScript consumer can produce or compare `ProjectConfig` values without file IO or +Effect: + +- `toProjectConfig(source)` — thin dispatcher over the two normalizers below; pass `{ cliConfig }` + or `{ apiResponse }`. Throws `ProjectConfigParseError` when `source` carries neither key or both. +- `fromConfigDocument(cliConfig)` — projection of a `CliConfig` document (or any `EffectiveConfig`) + onto the hosted sections (`api`, `auth`, `db`, `realtime`, `storage`, `workers`, `experimental`), + omitting every `x-secret` leaf and canonicalizing duration/byte-size fields the same way the API + side would. **Not a verbatim rendering of the document** — see + [ADR 0021](https://github.com/supabase/cli/blob/develop/docs/adr/0021-projectconfig-convergence-semantics.md) for the push-precedence + and sentinel-pruning semantics this applies. +- `fromApiProjectConfig(input)` — translation of a Management API v2 project-config response (the + full envelope, its `data` object, or bare `data.attributes`) via registry-driven renames, + boolean inversions, and unit conversions; lenient toward API keys this package version doesn't + yet know, and never reports a secret field's plaintext. Attaches a deep-frozen copy of the raw + attributes as a non-enumerable `_apiResponse` (invisible to encodes and structural walks, never + persisted — see [ADR 0019](https://github.com/supabase/cli/blob/develop/docs/adr/0019-config-api-response-passthrough.md)). Throws + `ProjectConfigParseError` on malformed input. +- `unmappedApiFields(projectConfig)` — the API fields this package version doesn't map, derived + from the same mapping registry. - `attachApiResponse(projectConfig, rawAttributes)` — re-attaches `_apiResponse` after a spread/`structuredClone`/state-store round-trip already dropped it. - `comparableProjectConfigPaths` / `isComparableProjectConfigPath(path)` — the registry-derived field paths `fromApiProjectConfig` can actually speak for, so a diff consumer restricts its comparison instead of hand-maintaining an equivalent field list. -`ProjectConfig` is sparse by design: it carries only what its source actually said, so it -composes with `subtractCliConfig`/`omitDefaultValues` (operand type `EffectiveConfig`) without -fabricating drift from schema defaults. Diffing two independently-sourced `ProjectConfig`s (a -remote response against a local document, rather than either against schema defaults) still needs -restricting to `comparableProjectConfigPaths`/`isComparableProjectConfigPath` — and at LEAF-path -granularity: `isComparableProjectConfigPath` takes a full path like -`["auth", "email", "smtp", "enabled"]`, not a top-level section name, so filtering -`Object.entries(overlay)` (section names only) restricts nothing. - ```ts -import { - subtractCliConfig, - toProjectConfig, - isComparableProjectConfigPath, -} from "@supabase/config"; +import { fromApiProjectConfig, fromConfigDocument, toProjectConfig } from "@supabase/config"; const remote = toProjectConfig({ apiResponse }); // Management API v2 project-config response -// `loaded` here is whatever `@supabase/config/io`'s loader returned (a -// `LoadedCliConfig`) — passing it directly (not just `loaded.config`) is the -// RECOMMENDED form: it unlocks the raw-presence masking described above. -const local = toProjectConfig({ cliConfig: loaded }); - -// `overlay` is what `local` says that `remote` doesn't already agree with. -const overlay = subtractCliConfig(local, remote); - -// Restrict to individual LEAF paths — see the granularity note above. -function leafPaths( - value: unknown, - prefix: ReadonlyArray = [], -): ReadonlyArray> { - if (value !== null && typeof value === "object" && !Array.isArray(value)) { - return Object.entries(value as Record).flatMap(([key, child]) => - leafPaths(child, [...prefix, key]), - ); - } - return [prefix]; +const local = toProjectConfig({ cliConfig: someCliConfig }); +``` + +### `ProjectConfigSchema`: runtime validation as another option + +For a consumer that already holds a well-typed `ProjectConfig` (produced by the converters above), +that's the whole story. A consumer that instead receives untrusted or serialized data — a value +read back from storage, sent over the wire, or produced by a third party — can validate it against +`ProjectConfigSchema` instead: + +```ts +import { ProjectConfigSchema } from "@supabase/config"; + +const result = await ProjectConfigSchema["~standard"].validate(candidate); +if (result.issues) { + // reject `candidate` — see the Standard Schema v1 spec for the `issues` shape } +``` -const restrictedDrift = leafPaths(overlay).filter(isComparableProjectConfigPath); -// e.g. [["api", "schemas"], ["api", "max_rows"], ["auth", "site_url"]] — the fields `local` -// DECLARES that `remote` doesn't already agree with, restricted to what `fromApiProjectConfig` -// can actually speak for. +> **Caution:** a key that isn't one of the seven hosted sections (or a field this schema version +> doesn't yet model) does not fail validation — it is silently dropped from `result.value` rather +> than rejected or preserved. Keep using your own `candidate` afterward if you need the original, +> unfiltered value. + +`ProjectConfigSchema` is a full Effect `Schema.Codec` (usable with `Schema.decodeUnknownEffect` +and friends) **and** a spec-compliant [Standard Schema v1](https://standardschema.dev/) object +(`~standard`) at the same time — `Schema.toStandardSchemaV1` augments and returns the same value +rather than wrapping it, so it works with any library that accepts a `~standard`-compatible +schema, not only Effect code. + +`ProjectConfigSchema` is derived from `CliConfigSchema`, never hand-declared, which gives it a +specific, narrower validation contract — what it does and does not promise: + +- **Hosted sections only** — the same seven sections `ProjectConfig` itself carries; nothing else + validates. +- **Deeply optional** — every key at every level is optional, mirroring `ProjectConfig`'s own + `DeepPartial` shape, so a sparse fragment like `{ auth: { email: { smtp: { enabled: true } } } }` + validates even without whatever sibling fields would otherwise be required. +- **`x-secret` leaves removed** — no secret-marked field exists in this schema at all, matching + the converters' own secret-omission behavior. +- **Cross-field checks stripped** — whole-struct business-rule refinements from the base schema + (e.g. "if `enabled`, then `host` is required") are removed, since a deliberately sparse overlay + can legitimately violate them. +- **Arrays are not deep-partialized** — an array field's element type is left untouched, matching + `ProjectConfig`'s own array handling. +- **Permissive, not closed** — never `additionalProperties: false`; an unrecognized own key (from + a schema version ahead of this package) is accepted, not rejected. +- **`_apiResponse` is out of scope** — it's a non-enumerable property, invisible to both decode and + validation. + +## `./io`'s error contract + +`./io` re-exports this package's entire pure surface (`export * from "."`, the same way `./effect` +does) alongside its seven Promise-returning functions, so one import from `@supabase/config/io` is +enough — no separate import from `.` needed to also name an error class, `CliConfigSchema`, or any +other pure export (those are all synchronous, exactly as on `.`). What `./io` adds on top — the +seven facade functions — is Promise-returning; among same-named `./effect` counterparts, only +`resolveCliConfigValue`/`resolveCliConfigSubtree` stay synchronous here, since they come from the +pure surface rather than the facade. + +`loadCliConfig`, `findCliProjectRoot`, `findCliProjectPaths`, and `loadCliProjectEnvironment` +resolve to `null` — they never reject — when there is simply no project or config file to find. +Rejection always means something was found but couldn't be read or understood (malformed config, +malformed env file, an OS-level failure); it never means "missing". `loadCliConfigFile` and +`saveCliConfig` have no such "missing" case (they name an exact path), so they only ever resolve or +reject. + +A rejected `loadCliConfig`, `loadCliConfigFile`, or `saveCliConfig` call from `./io` can reject +with any of: + +- `CliConfigParseError` — a malformed `supabase/config.toml`/`.json` +- `DuplicateRemoteProjectIdError` — two `[remotes.*]` blocks declare the same `project_id` +- `InvalidRemoteProjectIdError` — a `[remotes.*]` block's `project_id` isn't a valid project ref +- `CliProjectEnvParseError` — a malformed `.env`/`.env.local` file +- `PlatformError` (from `effect/PlatformError`) — a host/OS failure surfaced by the underlying + `FileSystem` service + +The four package-owned classes are plain Effect `Data.TaggedError` classes; `PlatformError` is +Effect's own error class rather than one of this package's — but all five are classes, so a catch +block can distinguish any of them with `instanceof`. What each carries: + +- `DuplicateRemoteProjectIdError` and `InvalidRemoteProjectIdError` set a real `error.message` + (verbatim the Go CLI's wording for the same failures). +- `PlatformError` sets `error.message` too, describing the failing filesystem operation, alongside + `.module`/`.method`/`.description`. +- `CliConfigParseError` and `CliProjectEnvParseError` carry structured fields instead of prose — + their `error.message` is empty. Build user-facing text from `CliConfigParseError.path`/`.format`/ + `.cause` (the `.cause` is typically a TOML or schema issue that itself carries line/column + location info worth surfacing) and `CliProjectEnvParseError.path`/`.line`. + +(`ProjectConfigParseError` is not part of this contract — it's thrown synchronously by the +`ProjectConfig` converters on the pure surface, and is documented in that section.) + +```ts +import { + CliConfigParseError, + DuplicateRemoteProjectIdError, + loadCliConfig, +} from "@supabase/config/io"; + +try { + const loaded = await loadCliConfig(process.cwd()); + if (loaded === null) { + // no supabase/config.toml or config.json in this project — not an error + return; + } +} catch (error) { + if (error instanceof CliConfigParseError) { + // malformed config.toml/config.json + } else if (error instanceof DuplicateRemoteProjectIdError) { + // two [remotes.*] blocks claim the same project_id + } + throw error; +} ``` -This example computes **one direction** of a drift check: values the local document declares that -differ from the remote. It does not surface remote-only settings — a field the API maps -unconditionally (e.g. `auth.email.smtp.enabled`) where the local document never declared the -subsection produces no leaf in this overlay at all. Finding those needs the reverse subtraction -(`subtractCliConfig(remote, local)`) intersected with the paths the document-side operand actually -declares, per the comparison contract on the `ProjectConfig` docstring — the comparable-path set -only says which paths the API mapper can represent, not which ones a given document spoke for. A -complete two-sided drift computation is `config diff`'s job (CLI-2156); this example is its -building block, not a substitute. +## Semver and the published contract + +The runtime export surface of `.`, `./io`, and `./effect`, plus the two generated JSON Schema +artifacts (`./schema.json`, `./project-schema.json`), is this package's published contract. +`./internal` carries no such guarantee. See [AGENTS.md](https://github.com/supabase/cli/blob/develop/packages/config/AGENTS.md) for how that contract is +enforced (export-surface snapshots, purity walkers, and a base-vs-head type-surface diff advisory +at PR time — a release-time tarball diff hard gate is planned under CLI-2233). ## Usage @@ -168,13 +359,15 @@ For convenience entrypoints at the runtime edge: import { loadCliConfig } from "@supabase/config/io"; ``` -For lazy `env(NAME)` resolution, load project env separately and resolve only the value or subtree you need: +For lazy `env(NAME)` resolution, load project env separately and resolve only the value or subtree +you need: ```ts import { loadCliProjectEnvironment, resolveCliConfigSubtree } from "@supabase/config/effect"; ``` -When both `supabase/config.json` and `supabase/config.toml` exist in one project, JSON wins. Saves preserve the existing format when possible and default new config files to JSON. +When both `supabase/config.json` and `supabase/config.toml` exist in one project, JSON wins. Saves +preserve the existing format when possible and default new config files to JSON. ## Architecture Docs @@ -194,5 +387,7 @@ Package-local checks and development commands run from `packages/config`: ```sh pnpm types:check pnpm run test # Run tests -pnpm run build # Generate dist/schema.json +pnpm run build # Compile dist/, generate schema.json/project-schema.json ``` + +See [AGENTS.md](https://github.com/supabase/cli/blob/develop/packages/config/AGENTS.md) for the build pipeline and contract-enforcement details. diff --git a/packages/config/docs/cli-config-loading.md b/packages/config/docs/cli-config-loading.md index 45266b7647..2851c81976 100644 --- a/packages/config/docs/cli-config-loading.md +++ b/packages/config/docs/cli-config-loading.md @@ -34,9 +34,8 @@ This document explains how the CLI's on-disk config document loading works, acro The `Cli*` prefix is a rule, not a per-name coincidence: it names the local checkout side — what the CLI reads, writes, or resolves about itself on disk. A bare `Project*` name is reserved for the hosted Supabase project. Value-helpers follow the config family regardless of their inputs, not the -shape of whatever they're passed — `resolveCliConfigValue` and `MissingCliConfigValueError` are -`Cli*`-named for this reason. See [ADR 0020](../../../docs/adr/0020-config-naming-vocabulary.md) -for the full decision record. +shape of whatever they're passed — `resolveCliConfigValue` is `Cli*`-named for this reason. See +[ADR 0020](../../../docs/adr/0020-config-naming-vocabulary.md) for the full decision record. Within `CliConfig` itself, `project_id` is overloaded by position: the root-scope `project_id` is a local identifier that defaults to the working directory name when running `supabase init` (see @@ -61,19 +60,17 @@ against `packages/config/src/node.ts`/`bun.ts`) are: - `loadCliConfig` - `saveCliConfig` - `loadCliConfigFile` -- `findCliProjectRootFor` -- `findCliProjectPathsFor` -- `loadCliProjectEnvironmentFor` -- `loadFunctionsManifest` - -`loadCliConfig`, `saveCliConfig`, and `loadCliConfigFile` share their name with the `./effect` -program they wrap (only the return type changes, `Effect` to `Promise`). The other four don't: -`findCliProjectRootFor`/`findCliProjectPathsFor`/`loadCliProjectEnvironmentFor` add a `For` suffix -their `./effect` counterparts (`findCliProjectRoot`, `findCliProjectPaths`, -`loadCliProjectEnvironment`) don't carry, and `loadFunctionsManifest` wraps `./effect`'s -`inferFunctionsManifest` under an unrelated verb. Settling this naming — the `For` suffix -convention, and the `loadFunctionsManifest`/`inferFunctionsManifest` divergence — is tracked in -CLI-2234. +- `findCliProjectRoot` +- `findCliProjectPaths` +- `loadCliProjectEnvironment` +- `inferFunctionsManifest` + +Every name here matches its `./effect` counterpart one-to-one (only the return type changes, +`Effect` to `Promise`) — the subpath itself (`/io` vs `/effect`) is what conveys Promise-vs-Effect. +`findCliProjectRootFor`/`findCliProjectPathsFor`/`loadCliProjectEnvironmentFor`/ +`loadFunctionsManifest` were the pre-CLI-2234 names: the first three carried a `For` suffix their +`./effect` counterparts didn't, and the fourth wrapped `./effect`'s `inferFunctionsManifest` under +an unrelated verb. CLI-2234 renamed all four to match. ## Overview @@ -209,10 +206,14 @@ literal, unresolved `env(NAME)`. ## Lazy `env(NAME)` Resolution A caller can also resolve `env(NAME)` references explicitly, after config is loaded. The package -exposes two helpers, from `@supabase/config/effect`: +exposes two helpers, under the same names from both `.` (plain, synchronous) and +`@supabase/config/effect` (Effect-typed; the Effect-typed variant wins when both are in scope via +`@supabase/config/effect`, since explicit named exports take precedence over a star re-export of +the same name). Neither has a failure mode: an unresolved `env(NAME)` reference is preserved +verbatim rather than rejected or thrown (see "Lazy `env(NAME)` Resolution" behavior below). -- `resolveCliConfigValue(value, cliProjectEnv, configPath, options?)` -- `resolveCliConfigSubtree(value, cliProjectEnv, pathPrefix, options?)` +- `resolveCliConfigValue(value, cliProjectEnv, configPath)` +- `resolveCliConfigSubtree(value, cliProjectEnv, pathPrefix)` Resolution only applies to exact whole-string matches of the form: @@ -237,7 +238,11 @@ resolves and redacts leaves nested inside `[remotes.*]` blocks. An optional `goViperCompat` flag switches the `env(NAME)` matcher from the default, strict `SCREAMING_SNAKE_CASE`-only pattern to Go/viper's case-agnostic `^env\((.*)\)$` form; only the -Go-parity legacy shell sets it. +Go-parity legacy shell sets it. The public `resolveCliConfigValue`/`resolveCliConfigSubtree` on +`.`/`./effect` take no options parameter at all (CLI-2234) — `goViperCompat` is internal-only, +typed on `InternalResolveCliConfigOptions`, a package-internal type that is not itself exported. +`@supabase/config/internal` re-exports these same runtime functions re-typed to additionally +accept it; `apps/cli`'s Go-parity call sites import from there instead. Callers such as `functions serve`/`functions dev`, `secrets set`, and `start` call these resolvers on the subtrees they actually need (e.g. `auth`, `edge_runtime`, `functions`), so dormant @@ -245,9 +250,8 @@ config — like a disabled Twilio block whose `auth_token` is still `env(TWILIO_ that variable was never set — never has to resolve at load time, and no caller pays for resolving or redacting a subtree it doesn't use. -The package still exports a `MissingCliConfigValueError` class, and `apps/cli` classifies it for -telemetry, but neither resolver raises it today: an unresolved `env(NAME)` reference is returned -as a plain string, not a typed failure. +Neither resolver ever fails: an unresolved `env(NAME)` reference is returned as a plain string, +not a typed failure. ## Secret Handling diff --git a/packages/config/package.json b/packages/config/package.json index 042c7974f4..b68df5b39a 100644 --- a/packages/config/package.json +++ b/packages/config/package.json @@ -2,17 +2,67 @@ "name": "@supabase/config", "version": "0.1.0", "private": true, + "description": "Supabase project configuration schema, parsing, and validation, built on Effect Schema.", + "keywords": [ + "config", + "effect", + "schema", + "supabase" + ], + "homepage": "https://github.com/supabase/cli#readme", + "bugs": { + "url": "https://github.com/supabase/cli/issues" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/supabase/cli.git", + "directory": "packages/config" + }, + "files": [ + "src", + "!src/**/*.test.ts", + "dist", + "docs" + ], "type": "module", + "sideEffects": false, "exports": { - ".": "./src/index.ts", + ".": { + "bun": "./src/index.ts", + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./internal": { + "bun": "./src/internal.ts", + "types": "./dist/internal.d.ts", + "default": "./dist/internal.js" + }, "./io": { "bun": "./src/bun.ts", - "node": "./src/node.ts", - "browser": "./src/io-browser.ts", - "default": "./src/node.ts" + "node": { + "types": "./dist/node.d.ts", + "default": "./dist/node.js" + }, + "browser": { + "types": "./dist/io-browser.d.ts", + "default": "./dist/io-browser.js" + }, + "default": { + "types": "./dist/node.d.ts", + "default": "./dist/node.js" + } + }, + "./effect": { + "bun": "./src/effect.ts", + "types": "./dist/effect.d.ts", + "default": "./dist/effect.js" }, - "./effect": "./src/effect.ts", - "./schema.json": "./dist/schema.json" + "./schema.json": "./dist/schema.json", + "./project-schema.json": "./dist/project-schema.json" + }, + "publishConfig": { + "access": "public" }, "scripts": { "build": "bun run ./scripts/build.ts", @@ -22,6 +72,7 @@ "test:unit:run": "bun --bun vitest run --project unit --coverage.reportsDirectory=coverage/unit" }, "dependencies": { + "@standard-schema/spec": "^1.1.0", "dedent": "^1.7.2", "smol-toml": "^1.8.0" }, @@ -33,12 +84,13 @@ "@vitest/coverage-istanbul": "catalog:", "effect": "catalog:", "typescript": "catalog:", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "vitest": "catalog:" }, "peerDependencies": { - "@effect/platform-bun": "catalog:", - "@effect/platform-node": "catalog:", - "effect": "catalog:" + "@effect/platform-bun": ">=4.0.0-rc.111 <5", + "@effect/platform-node": ">=4.0.0-rc.111 <5", + "effect": ">=4.0.0-rc.111 <5" }, "peerDependenciesMeta": { "@effect/platform-bun": { @@ -47,5 +99,8 @@ "@effect/platform-node": { "optional": true } + }, + "engines": { + "node": ">=20" } } diff --git a/packages/config/scripts/build-artifacts.unit.test.ts b/packages/config/scripts/build-artifacts.unit.test.ts new file mode 100644 index 0000000000..479e6a6e46 --- /dev/null +++ b/packages/config/scripts/build-artifacts.unit.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, test } from "vitest"; +import { CliConfigSchema, toCliConfigJsonSchema } from "../src/base.ts"; +import { + ProjectConfigSchema, + toProjectConfigJsonSchema, +} from "../src/project-config/project-schema.ts"; +import { CLI_CONFIG_SCHEMA_URL, PROJECT_CONFIG_SCHEMA_URL } from "../src/schema-metadata.ts"; +import { collapseNonFiniteNumberUnions, withSchemaMetadata } from "./json-schema-postprocess.ts"; + +// CLI-2234 group 6c: regression coverage for the exact post-processing +// `scripts/build.ts` applies to both `dist/schema.json` and +// `dist/project-schema.json` — generated in-memory here (no real build), via +// the same pure functions the build script itself calls, against the real +// `CliConfigSchema`/`ProjectConfigSchema`. + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function findAnyOfWithNonFiniteEnum(node: unknown, into: Array): void { + if (Array.isArray(node)) { + for (const item of node) { + findAnyOfWithNonFiniteEnum(item, into); + } + return; + } + if (!isRecord(node)) { + return; + } + const anyOf = node["anyOf"]; + if (Array.isArray(anyOf) && anyOf.length === 2) { + const hasNumber = anyOf.some((branch) => isRecord(branch) && branch["type"] === "number"); + const hasNonFiniteEnum = anyOf.some( + (branch) => + isRecord(branch) && + branch["type"] === "string" && + Array.isArray(branch["enum"]) && + branch["enum"].every( + (value) => typeof value === "string" && ["Infinity", "-Infinity", "NaN"].includes(value), + ), + ); + if (hasNumber && hasNonFiniteEnum) { + into.push(node); + } + } + for (const value of Object.values(node)) { + findAnyOfWithNonFiniteEnum(value, into); + } +} + +const cliDocument = withSchemaMetadata( + collapseNonFiniteNumberUnions(toCliConfigJsonSchema(), CliConfigSchema.ast) as Record< + string, + unknown + >, + { + id: CLI_CONFIG_SCHEMA_URL, + title: "Supabase CLI config (CliConfig)", + description: "test", + }, +); + +const projectDocument = withSchemaMetadata( + collapseNonFiniteNumberUnions(toProjectConfigJsonSchema(), ProjectConfigSchema.ast) as Record< + string, + unknown + >, + { + id: PROJECT_CONFIG_SCHEMA_URL, + title: "Supabase hosted project config (ProjectConfig)", + description: "test", + }, +); + +describe("generated JSON Schema artifacts, post-processed", () => { + test.each([ + ["schema.json", cliDocument], + ["project-schema.json", projectDocument], + ])("%s carries no anyOf-with-non-finite-enum pattern anywhere", (_name, document) => { + const matches: Array = []; + findAnyOfWithNonFiniteEnum(document, matches); + expect(matches).toEqual([]); + }); + + test("schema.json's api.max_rows carries both description and default", () => { + const properties = cliDocument["properties"]; + if (!isRecord(properties) || !isRecord(properties["api"])) { + throw new Error("expected properties.api to be an object"); + } + const apiProperties = properties["api"]["properties"]; + if (!isRecord(apiProperties) || !isRecord(apiProperties["max_rows"])) { + throw new Error("expected properties.api.properties.max_rows to be an object"); + } + const maxRows = apiProperties["max_rows"]; + expect(maxRows["type"]).toBe("number"); + expect(typeof maxRows["description"]).toBe("string"); + expect(maxRows["default"]).toBe(1000); + }); + + test.each([ + ["schema.json", cliDocument, CLI_CONFIG_SCHEMA_URL, "Supabase CLI config (CliConfig)"], + [ + "project-schema.json", + projectDocument, + PROJECT_CONFIG_SCHEMA_URL, + "Supabase hosted project config (ProjectConfig)", + ], + ])("%s carries $schema, $id, and title", (_name, document, id, title) => { + expect(document["$schema"]).toBe("https://json-schema.org/draft/2020-12/schema"); + expect(document["$id"]).toBe(id); + expect(document["title"]).toBe(title); + }); +}); diff --git a/packages/config/scripts/build.ts b/packages/config/scripts/build.ts index 66b19cde69..0aaec37bc8 100644 --- a/packages/config/scripts/build.ts +++ b/packages/config/scripts/build.ts @@ -1,25 +1,422 @@ -import { mkdir } from "node:fs/promises"; -import { toCliConfigJsonSchema } from "../src/base.ts"; - -const json = toCliConfigJsonSchema(); -const schema = `${JSON.stringify(json, null, 2)}\n`; - -const formatter = Bun.spawn(["bun", "x", "oxfmt", "--stdin-filepath=./dist/schema.json"], { - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", -}); -await formatter.stdin.write(schema); -await formatter.stdin.end(); - -const [exitCode, formatted, stderr] = await Promise.all([ - formatter.exited, - new Response(formatter.stdout).text(), - new Response(formatter.stderr).text(), -]); -if (exitCode !== 0) { - throw new Error(`oxfmt failed with exit code ${exitCode}: ${stderr.trim()}`); -} - -await mkdir("./dist", { recursive: true }); -await Bun.write("./dist/schema.json", formatted); +import { mkdir, mkdtemp, realpath, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { CliConfigSchema, toCliConfigJsonSchema } from "../src/base.ts"; +import { + ProjectConfigSchema, + toProjectConfigJsonSchema, +} from "../src/project-config/project-schema.ts"; +import { CLI_CONFIG_SCHEMA_URL, PROJECT_CONFIG_SCHEMA_URL } from "../src/schema-metadata.ts"; +import { collapseNonFiniteNumberUnions, withSchemaMetadata } from "./json-schema-postprocess.ts"; + +const packageRoot = path.resolve(import.meta.dir, ".."); + +async function runCommand(cmd: readonly string[], cwd: string = packageRoot): Promise { + const child = Bun.spawn([...cmd], { cwd, stdout: "inherit", stderr: "inherit" }); + const exitCode = await child.exited; + if (exitCode !== 0) { + throw new Error(`\`${cmd.join(" ")}\` failed with exit code ${exitCode}`); + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +/** Descends `root` at each of `path`, requiring an object at every intermediate step, throwing with a precise location otherwise. */ +function readStringAt(root: unknown, path: ReadonlyArray): string { + let current = root; + for (const [index, key] of path.entries()) { + if (!isRecord(current)) { + throw new Error( + `expected an object while reading ${path.slice(0, index).join(".")} (looking for "${key}"), got ${typeof current}`, + ); + } + current = current[key]; + } + if (typeof current !== "string") { + throw new Error(`expected a string at ${path.join(".")}, got ${typeof current}`); + } + return current; +} + +/** + * Runs a schema's rendered JSON Schema document through + * {@link collapseNonFiniteNumberUnions} and {@link withSchemaMetadata}, then + * writes it via {@link renderJsonSchema}. `collapseNonFiniteNumberUnions` + * returns `unknown` (it's a generic JSON-tree walk with no static shape + * guarantee); this narrows it back to an object via `isRecord` rather than an + * `as` cast — both `toCliConfigJsonSchema()`/`toProjectConfigJsonSchema()` + * always render a top-level object, so a non-object result here would mean + * the collapse walk itself is broken, worth failing loudly on. + */ +async function renderCollapsedJsonSchema( + outputPath: string, + document: unknown, + rootAst: Parameters[1], + metadata: Parameters[1], +): Promise { + const collapsed = collapseNonFiniteNumberUnions(document, rootAst); + if (!isRecord(collapsed)) { + throw new Error(`collapseNonFiniteNumberUnions did not return an object for ${outputPath}`); + } + await renderJsonSchema(outputPath, withSchemaMetadata(collapsed, metadata)); +} + +async function renderJsonSchema(outputPath: string, json: Record): Promise { + const schema = `${JSON.stringify(json, null, 2)}\n`; + + const formatter = Bun.spawn(["bun", "x", "oxfmt", `--stdin-filepath=${outputPath}`], { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }); + await formatter.stdin.write(schema); + await formatter.stdin.end(); + + const [exitCode, formatted, stderr] = await Promise.all([ + formatter.exited, + new Response(formatter.stdout).text(), + new Response(formatter.stderr).text(), + ]); + if (exitCode !== 0) { + throw new Error(`oxfmt failed with exit code ${exitCode}: ${stderr.trim()}`); + } + + await mkdir(path.dirname(outputPath), { recursive: true }); + await Bun.write(outputPath, formatted); +} + +/** + * Proves the package.json `sideEffects: false` claim (a deferred CLI-2230 + * review item) against the real compiled `dist/index.js`, rather than merely + * asserting it. Bundles a probe importing ONLY `CliConfigSchema` for a + * browser-ish target: `project-schema.ts`'s import-time invariant guard (and + * the rest of the `./project-config/registry*.ts` graph it pulls in) must be + * droppable even though it contains real side-effecting statements — that's + * exactly what `sideEffects: false` authorizes a bundler to do, and exactly + * what this asserts actually happened. A second, positive-control probe + * (bundling `projectConfigMappingRows` from `dist/internal.js`) proves the + * registry-only marker this test looks for is actually detectable by this + * exact bundling method in the first place, before trusting its absence from + * the first probe as meaningful. + */ +async function verifyTreeShaking(): Promise { + const distIndexPath = await realpath(path.join(packageRoot, "dist", "index.js")); + const distInternalPath = await realpath(path.join(packageRoot, "dist", "internal.js")); + // `mkdtemp` can return a path through a symlinked prefix (e.g. macOS's + // `/var` -> `/private/var`) that Bun's bundler resolves to its canonical + // form internally when computing the probe entry's own directory — compute + // the relative specifier against that same canonical form, or a + // `path.relative` mismatch silently produces a specifier with one too many + // `../` segments. + const probeDir = await realpath( + await mkdtemp(path.join(tmpdir(), "supabase-config-tree-shake-")), + ); + + try { + function relativeSpecifierFor(target: string): string { + const relative = path.relative(probeDir, target).split(path.sep).join("/"); + return relative.startsWith(".") ? relative : `./${relative}`; + } + + async function bundle(entryName: string, source: string): Promise { + const probeEntry = path.join(probeDir, entryName); + await Bun.write(probeEntry, source); + const result = await Bun.build({ + entrypoints: [probeEntry], + target: "browser", + minify: false, + }); + if (!result.success) { + const messages = result.logs.map((log) => log.message).join("\n"); + throw new Error(`tree-shake probe failed to bundle ${entryName}:\n${messages}`); + } + const [output] = result.outputs; + if (!output) { + throw new Error(`tree-shake probe produced no bundle output for ${entryName}`); + } + return output.text(); + } + + // Only appears in `src/project-config/registry*.ts` (verified by + // grepping `dist/`) — a real API attribute path segment, never used by + // `CliConfigSchema`'s own field names (`base.ts`/`api.ts` use `schemas`, + // not `db_schema`). + const REGISTRY_ONLY_MARKER = "db_schema"; + // Derived at runtime from the actual `CliConfigSchema` annotation it + // names, rather than hardcoded prose that could silently drift from the + // real description text — `api.enabled`'s `description`, read off the + // real rendered JSON Schema document (the same source `dist/schema.json` + // is built from). + const SCHEMA_MARKER = readStringAt(toCliConfigJsonSchema(), [ + "properties", + "api", + "properties", + "enabled", + "description", + ]); + + const positiveControlCode = await bundle( + "positive-control.js", + `export { projectConfigMappingRows } from "${relativeSpecifierFor(distInternalPath)}";\n`, + ); + if (!positiveControlCode.includes(REGISTRY_ONLY_MARKER)) { + throw new Error( + `tree-shake probe's positive control failed: bundling { projectConfigMappingRows } from ` + + `dist/internal.js did not include marker ${JSON.stringify(REGISTRY_ONLY_MARKER)} — this ` + + `probe methodology can no longer detect the marker it's meant to prove absent below, so its ` + + `absence from the CliConfigSchema-only probe would be meaningless.`, + ); + } + + const code = await bundle( + "probe.js", + `export { CliConfigSchema } from "${relativeSpecifierFor(distIndexPath)}";\n`, + ); + + if (code.includes(REGISTRY_ONLY_MARKER)) { + throw new Error( + `tree-shake probe failed: bundling only { CliConfigSchema } from dist/index.js still pulled in ` + + `registry-only code (found marker ${JSON.stringify(REGISTRY_ONLY_MARKER)} from ` + + `project-config/registry.ts). "sideEffects": false is not holding for this package — ` + + `investigate before trusting the tree-shaking claim.`, + ); + } + if (!code.includes(SCHEMA_MARKER)) { + throw new Error( + `tree-shake probe failed: expected schema marker ${JSON.stringify(SCHEMA_MARKER)} is missing from ` + + `the bundle output — the probe isn't actually exercising CliConfigSchema.`, + ); + } + + console.log( + `[build] tree-shake probe OK (${code.length} bytes; registry-only marker absent, schema marker present, positive control passed).`, + ); + } finally { + await rm(probeDir, { recursive: true, force: true }); + } +} + +const SMOKE_TEST_RUNTIME_DEPS = [ + "effect", + "@effect/platform-node", + "@standard-schema/spec", + "dedent", + "smol-toml", +] as const; + +function buildSmokeTestScript(): string { + return [ + 'import assert from "node:assert/strict";', + 'import { createRequire } from "node:module";', + "", + 'import { CliConfigSchema, ProjectConfigSchema, toProjectConfigJsonSchema, toCliConfigJsonSchema } from "@supabase/config";', + "assert.ok(CliConfigSchema, \"CliConfigSchema missing from '.'\");", + "assert.ok(ProjectConfigSchema, \"ProjectConfigSchema missing from '.'\");", + "assert.ok(toProjectConfigJsonSchema, \"toProjectConfigJsonSchema missing from '.'\");", + 'assert.equal(typeof toCliConfigJsonSchema(), "object", "toCliConfigJsonSchema() did not return an object");', + "", + 'const effectMod = await import("@supabase/config/effect");', + "assert.ok(effectMod.loadCliConfig, \"loadCliConfig missing from './effect'\");", + "", + 'const ioMod = await import("@supabase/config/io");', + "assert.ok(ioMod.loadCliConfig, \"loadCliConfig missing from './io'\");", + "assert.ok(ioMod.inferFunctionsManifest, \"inferFunctionsManifest missing from './io'\");", + "", + 'const internalMod = await import("@supabase/config/internal");', + "assert.ok(internalMod.projectConfigMappingRows, \"projectConfigMappingRows missing from './internal'\");", + "", + "const require = createRequire(import.meta.url);", + 'const schemaJson = require("@supabase/config/schema.json");', + 'const projectSchemaJson = require("@supabase/config/project-schema.json");', + 'assert.equal(typeof schemaJson, "object", "schema.json did not resolve to an object");', + 'assert.equal(typeof projectSchemaJson, "object", "project-schema.json did not resolve to an object");', + "", + 'console.log("[build] pack-and-install smoke test: every entrypoint resolved through a real npm-packed tarball install");', + ].join("\n"); +} + +/** + * The real CLI-2232/CLI-2234 acceptance check: packs the actual publish + * tarball (`npm pack`, governed by `files`/`.npmignore` — the exact thing + * `npm publish` would ship) and installs it into a fresh, isolated consumer + * project, then imports every entrypoint and JSON artifact through a real + * `node` process. This catches `files`/`exports` drift the previous + * workspace-link Node smoke test missed entirely (a workspace `pnpm` link + * resolves straight to this package's own directory, bypassing `files` + * filtering altogether). + * + * Deliberately extracts the tarball directly (`tar`) rather than `npm install + * `: the latter would additionally try to resolve + * `@supabase/config`'s own dependency tree (`effect`'s own `fast-check`/ + * `msgpackr`, `@effect/platform-node`'s `undici`/`mime`, …) from the npm + * registry over the network on every build. Every runtime dependency this + * smoke test actually needs is already resolved locally by pnpm — symlinking + * those real, already-resolved package directories in below — the same + * directories `packages/config/node_modules/*` itself points at — mirrors + * exactly how pnpm links every other workspace in this monorepo (Node + * resolves each symlink to its real path before walking further ancestor + * `node_modules` directories, so each linked package's own transitive deps, + * already resolved alongside it in the pnpm store, are found the same way). + * This keeps the check hermetic, fast, and network-free. + */ +async function runPackAndInstallSmokeTest(): Promise { + const npmPath = Bun.which("npm"); + const nodePath = Bun.which("node"); + const tarPath = Bun.which("tar"); + if (npmPath === null || nodePath === null || tarPath === null) { + const missing = [ + npmPath === null ? "npm" : null, + nodePath === null ? "node" : null, + tarPath === null ? "tar" : null, + ].filter((name) => name !== null); + throw new Error( + `the pack-and-install smoke test (CLI-2234) requires ${missing.join(", ")} on PATH — install ` + + "it (mise provides node/npm; tar ships with every supported OS) before running `pnpm build`.", + ); + } + + const scratchDir = await mkdtemp(path.join(tmpdir(), "supabase-config-pack-smoke-")); + try { + const packResult = Bun.spawn([npmPath, "pack", "--json", "--pack-destination", scratchDir], { + cwd: packageRoot, + stdout: "pipe", + stderr: "inherit", + }); + const [packExitCode, packStdout] = await Promise.all([ + packResult.exited, + new Response(packResult.stdout).text(), + ]); + if (packExitCode !== 0) { + throw new Error(`\`npm pack\` failed with exit code ${packExitCode}`); + } + const packEntries = JSON.parse(packStdout) as ReadonlyArray<{ readonly filename: string }>; + const [packEntry] = packEntries; + if (packEntry === undefined) { + throw new Error("`npm pack --json` produced no tarball entries"); + } + const tarballPath = path.join(scratchDir, packEntry.filename); + + const consumerDir = path.join(scratchDir, "consumer"); + const consumerConfigDir = path.join(consumerDir, "node_modules", "@supabase", "config"); + await mkdir(consumerConfigDir, { recursive: true }); + await runCommand([ + tarPath, + "-xzf", + tarballPath, + "-C", + consumerConfigDir, + "--strip-components=1", + ]); + + await Bun.write( + path.join(consumerDir, "package.json"), + `${JSON.stringify( + { name: "supabase-config-pack-smoke", version: "0.0.0", private: true, type: "module" }, + null, + 2, + )}\n`, + ); + + const consumerNodeModules = path.join(consumerDir, "node_modules"); + for (const name of SMOKE_TEST_RUNTIME_DEPS) { + const real = await realpath(path.join(packageRoot, "node_modules", name)); + const dest = path.join(consumerNodeModules, name); + await mkdir(path.dirname(dest), { recursive: true }); + await symlink(real, dest, "dir"); + } + + await runCommand([nodePath, "--input-type=module", "-e", buildSmokeTestScript()], consumerDir); + } finally { + await rm(scratchDir, { recursive: true, force: true }); + } +} + +interface ExportsMap { + readonly [subpath: string]: ExportsNode; +} +type ExportsNode = string | { readonly [condition: string]: ExportsNode }; + +function collectDistTargets(node: ExportsNode, into: Set): void { + if (typeof node === "string") { + if (node.startsWith("./dist/")) { + into.add(node); + } + return; + } + for (const [condition, value] of Object.entries(node)) { + // `bun` conditions point at `src/*.ts`, which trivially exists at every + // commit (it's source, not a build output) — nothing to verify here. + if (condition === "bun") { + continue; + } + collectDistTargets(value, into); + } +} + +/** CLI-2234: every `types`/`default`/JSON-artifact target the exports map declares must exist once the build finishes. */ +async function verifyExportsMapTargetsExist(): Promise { + const packageJson = JSON.parse(await Bun.file(path.join(packageRoot, "package.json")).text()) as { + readonly exports: ExportsMap; + }; + + const targets = new Set(); + for (const node of Object.values(packageJson.exports)) { + collectDistTargets(node, targets); + } + + const missing: string[] = []; + for (const target of targets) { + if (!(await Bun.file(path.join(packageRoot, target)).exists())) { + missing.push(target); + } + } + if (missing.length > 0) { + throw new Error( + `the following dist targets declared in package.json's exports map are missing after the ` + + `build: ${missing.join(", ")}`, + ); + } + console.log(`[build] verified ${targets.size} exports-map dist targets exist on disk.`); +} + +console.log("[build] removing stale dist/ (stale modules from renames must not ship)..."); +await rm(path.join(packageRoot, "dist"), { recursive: true, force: true }); + +console.log("[build] compiling TypeScript project (tsconfig.build.json)..."); +await runCommand(["pnpm", "exec", "tsc", "-p", "tsconfig.build.json"]); + +console.log("[build] rendering JSON Schema artifacts..."); +await renderCollapsedJsonSchema( + path.join(packageRoot, "dist", "schema.json"), + toCliConfigJsonSchema(), + CliConfigSchema.ast, + { + id: CLI_CONFIG_SCHEMA_URL, + title: "Supabase CLI config (CliConfig)", + description: + "The Supabase CLI's local project config document (supabase/config.toml or supabase/config.json).", + }, +); +await renderCollapsedJsonSchema( + path.join(packageRoot, "dist", "project-schema.json"), + toProjectConfigJsonSchema(), + ProjectConfigSchema.ast, + { + id: PROJECT_CONFIG_SCHEMA_URL, + title: "Supabase hosted project config (ProjectConfig)", + description: "The sparse, hosted-project subset of CliConfig that a Supabase project manages.", + }, +); + +console.log("[build] verifying every exports-map dist target exists..."); +await verifyExportsMapTargetsExist(); + +console.log("[build] verifying the sideEffects:false tree-shaking claim..."); +await verifyTreeShaking(); + +console.log("[build] running the pack-and-install smoke test..."); +await runPackAndInstallSmokeTest(); + +console.log("[build] done."); diff --git a/packages/config/scripts/json-schema-postprocess.ts b/packages/config/scripts/json-schema-postprocess.ts new file mode 100644 index 0000000000..e70c5e7484 --- /dev/null +++ b/packages/config/scripts/json-schema-postprocess.ts @@ -0,0 +1,239 @@ +import { SchemaAST } from "effect"; + +/** + * Pure JSON Schema post-processing used by `build.ts`'s `renderJsonSchema` on + * both generated artifacts (`dist/schema.json`, `dist/project-schema.json`). + * Extracted to its own module (rather than inlined in `build.ts`) so + * `json-schema-postprocess.unit.test.ts` can exercise it directly against an + * in-memory document, without spawning the real build. + */ + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +const NON_FINITE_ENUM_VALUES: ReadonlySet = new Set(["Infinity", "-Infinity", "NaN"]); + +function isNonFiniteStringEnumNode(node: unknown): boolean { + if (!isRecord(node) || node["type"] !== "string") { + return false; + } + const values = node["enum"]; + return ( + Array.isArray(values) && + values.length > 0 && + values.every((value) => typeof value === "string" && NON_FINITE_ENUM_VALUES.has(value)) + ); +} + +function isPlainNumberNode(node: unknown): node is Record { + return isRecord(node) && node["type"] === "number"; +} + +interface RecoveredAnnotations { + readonly description?: string; + readonly default?: unknown; +} + +/** + * `description`/`default` for every `Schema.Number` leaf reachable from + * `ast`, keyed by dotted property path (`"*"` for a record/array element) — + * the only two annotations Effect's `Schema.toJsonSchemaDocument` silently + * drops when it splits a plain `Schema.Number` into the `anyOf` union + * {@link collapseNonFiniteNumberUnions} collapses back down (verified + * empirically against `api.max_rows`, which carries both). A `.check()`ed + * number (e.g. `workers.*.instances`'s `isInt()`) renders as a plain + * `"type": "integer"` node instead of this union, so it never reaches this + * map's consumer in the first place — collected here regardless, since nothing + * downstream keys off `_tag` other than `"Number"`. + */ +function collectNumberLeafAnnotations( + ast: SchemaAST.AST, + prefix: ReadonlyArray = [], + into: Map = new Map(), +): Map { + if (SchemaAST.isObjects(ast)) { + for (const property of ast.propertySignatures) { + collectNumberLeafAnnotations(property.type, [...prefix, String(property.name)], into); + } + for (const indexSignature of ast.indexSignatures) { + collectNumberLeafAnnotations(indexSignature.type, [...prefix, "*"], into); + } + } else if (SchemaAST.isArrays(ast)) { + for (const element of ast.elements) { + collectNumberLeafAnnotations(element, [...prefix, "*"], into); + } + for (const rest of ast.rest) { + collectNumberLeafAnnotations(rest, [...prefix, "*"], into); + } + } else if (SchemaAST.isUnion(ast)) { + for (const member of ast.types) { + collectNumberLeafAnnotations(member, prefix, into); + } + } else if (ast._tag === "Number") { + const description = ast.annotations?.["description"]; + const defaultValue = ast.annotations?.["default"]; + if (typeof description === "string" || defaultValue !== undefined) { + into.set(prefix.join("."), { + ...(typeof description === "string" ? { description } : {}), + ...(defaultValue !== undefined ? { default: defaultValue } : {}), + }); + } + } + return into; +} + +function tryCollapseNonFiniteNumberUnion( + node: Record, + path: ReadonlyArray, + annotationsByPath: ReadonlyMap, +): Record | undefined { + const anyOf = node["anyOf"]; + if (!Array.isArray(anyOf) || anyOf.length !== 2) { + return undefined; + } + const [first, second] = anyOf; + const numberNode = isPlainNumberNode(first) + ? first + : isPlainNumberNode(second) + ? second + : undefined; + const enumNode = isNonFiniteStringEnumNode(first) + ? first + : isNonFiniteStringEnumNode(second) + ? second + : undefined; + if (numberNode === undefined || enumNode === undefined) { + return undefined; + } + + const { anyOf: _anyOf, ...siblings } = node; + const merged: Record = { ...numberNode, ...siblings }; + const recovered = annotationsByPath.get(path.join(".")); + if (merged["description"] === undefined && recovered?.description !== undefined) { + merged["description"] = recovered.description; + } + if (merged["default"] === undefined && recovered?.default !== undefined) { + merged["default"] = recovered.default; + } + return merged; +} + +function collapseSchemaNode( + node: unknown, + path: ReadonlyArray, + annotationsByPath: ReadonlyMap, +): unknown { + if (!isRecord(node)) { + return node; + } + + const collapsed = tryCollapseNonFiniteNumberUnion(node, path, annotationsByPath); + if (collapsed !== undefined) { + return collapsed; + } + + const result: Record = { ...node }; + + const properties = node["properties"]; + if (isRecord(properties)) { + result["properties"] = Object.fromEntries( + Object.entries(properties).map(([name, child]) => [ + name, + collapseSchemaNode(child, [...path, name], annotationsByPath), + ]), + ); + } + + const patternProperties = node["patternProperties"]; + if (isRecord(patternProperties)) { + result["patternProperties"] = Object.fromEntries( + Object.entries(patternProperties).map(([pattern, child]) => [ + pattern, + collapseSchemaNode(child, [...path, "*"], annotationsByPath), + ]), + ); + } + + const additionalProperties = node["additionalProperties"]; + if (isRecord(additionalProperties)) { + result["additionalProperties"] = collapseSchemaNode( + additionalProperties, + [...path, "*"], + annotationsByPath, + ); + } + + const items = node["items"]; + if (items !== undefined) { + result["items"] = collapseSchemaNode(items, [...path, "*"], annotationsByPath); + } + + const prefixItems = node["prefixItems"]; + if (Array.isArray(prefixItems)) { + result["prefixItems"] = prefixItems.map((item) => + collapseSchemaNode(item, [...path, "*"], annotationsByPath), + ); + } + + for (const combinator of ["anyOf", "allOf", "oneOf"] as const) { + const branches = node[combinator]; + if (Array.isArray(branches)) { + result[combinator] = branches.map((branch) => + collapseSchemaNode(branch, path, annotationsByPath), + ); + } + } + + const defs = node["$defs"]; + if (isRecord(defs)) { + result["$defs"] = Object.fromEntries( + Object.entries(defs).map(([name, child]) => [ + name, + // `$defs` members don't correspond to a reachable property path off + // `ast` (they're keyed by ref name, not position) — pass `path` + // through unchanged. Neither generated document actually emits + // `$defs` today (no shared/recursive substructure), so this is inert. + collapseSchemaNode(child, path, annotationsByPath), + ]), + ); + } + + return result; +} + +/** + * Collapses every `anyOf: [{ type: "number", ... }, { type: "string", enum: + * [subset of "Infinity"/"-Infinity"/"NaN"] }]` node anywhere in `document` + * down to the plain `{ type: "number", ... }` branch, re-attaching that + * leaf's `description`/`default` from `rootAst` when the union node itself + * doesn't already carry them (Effect's `Schema.toJsonSchemaDocument` drops + * both when it renders a plain `Schema.Number` as this non-finite-safe + * union). `rootAst` must be the same schema `document` was rendered from. + */ +export function collapseNonFiniteNumberUnions(document: unknown, rootAst: SchemaAST.AST): unknown { + const annotationsByPath = collectNumberLeafAnnotations(rootAst); + return collapseSchemaNode(document, [], annotationsByPath); +} + +/** + * Injects `$id`/`title`/`description` right after `$schema`, ahead of the + * rest of the document's own keys — used by `build.ts` on both generated + * artifacts (CLI-2234). `metadata` is authoritative: any `$id`/`title`/ + * `description` already present on the incoming `document` is discarded + * rather than allowed to win over the caller-supplied values through the + * trailing `...rest` spread. + */ +export function withSchemaMetadata( + document: Record, + metadata: { readonly id: string; readonly title: string; readonly description: string }, +): Record { + const { $schema, $id: _id, title: _title, description: _description, ...rest } = document; + return { + $schema, + $id: metadata.id, + title: metadata.title, + description: metadata.description, + ...rest, + }; +} diff --git a/packages/config/scripts/json-schema-postprocess.unit.test.ts b/packages/config/scripts/json-schema-postprocess.unit.test.ts new file mode 100644 index 0000000000..1bf52c6221 --- /dev/null +++ b/packages/config/scripts/json-schema-postprocess.unit.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, test } from "vitest"; +import { Schema } from "effect"; +import { collapseNonFiniteNumberUnions, withSchemaMetadata } from "./json-schema-postprocess.ts"; + +describe("collapseNonFiniteNumberUnions", () => { + test("collapses a top-level anyOf-with-non-finite-enum node to a plain number", () => { + const schema = Schema.Struct({ port: Schema.Number }); + const document = { + properties: { + port: { + anyOf: [{ type: "number" }, { type: "string", enum: ["Infinity", "-Infinity", "NaN"] }], + }, + }, + }; + + const result = collapseNonFiniteNumberUnions(document, schema.ast); + + expect(result).toEqual({ properties: { port: { type: "number" } } }); + }); + + test("re-attaches description/default from the source AST when missing on the union node", () => { + const schema = Schema.Struct({ + max_rows: Schema.Number.annotate({ description: "Row limit.", default: 1000 }), + }); + const document = { + properties: { + max_rows: { + anyOf: [{ type: "number" }, { type: "string", enum: ["Infinity", "-Infinity", "NaN"] }], + }, + }, + }; + + const result = collapseNonFiniteNumberUnions(document, schema.ast) as { + properties: { max_rows: Record }; + }; + + expect(result.properties.max_rows).toEqual({ + type: "number", + description: "Row limit.", + default: 1000, + }); + }); + + test("does not override description/default already present on the union node", () => { + const schema = Schema.Struct({ + max_rows: Schema.Number.annotate({ description: "Row limit.", default: 1000 }), + }); + const document = { + properties: { + max_rows: { + anyOf: [{ type: "number" }, { type: "string", enum: ["Infinity", "-Infinity", "NaN"] }], + description: "Overridden description.", + }, + }, + }; + + const result = collapseNonFiniteNumberUnions(document, schema.ast) as { + properties: { max_rows: Record }; + }; + + expect(result.properties.max_rows["description"]).toBe("Overridden description."); + expect(result.properties.max_rows["default"]).toBe(1000); + }); + + test("leaves an unrelated anyOf (e.g. object-or-null) untouched", () => { + const schema = Schema.Struct({ workers: Schema.Unknown }); + const document = { + properties: { + workers: { anyOf: [{ type: "object" }, { type: "null" }] }, + }, + }; + + const result = collapseNonFiniteNumberUnions(document, schema.ast); + + expect(result).toEqual(document); + }); + + test("descends through properties, patternProperties, items, and additionalProperties", () => { + const schema = Schema.Struct({ + list: Schema.Array(Schema.Number), + table: Schema.Record(Schema.String, Schema.Number), + }); + const nonFiniteNumberNode = { + anyOf: [{ type: "number" }, { type: "string", enum: ["Infinity", "-Infinity", "NaN"] }], + }; + const document = { + properties: { + list: { type: "array", items: nonFiniteNumberNode }, + table: { type: "object", patternProperties: { ".*": nonFiniteNumberNode } }, + }, + }; + + const result = collapseNonFiniteNumberUnions(document, schema.ast); + + expect(result).toEqual({ + properties: { + list: { type: "array", items: { type: "number" } }, + table: { type: "object", patternProperties: { ".*": { type: "number" } } }, + }, + }); + }); + + test("leaves a plain non-object document untouched", () => { + expect(collapseNonFiniteNumberUnions("not-a-schema-doc", Schema.String.ast)).toBe( + "not-a-schema-doc", + ); + }); +}); + +describe("withSchemaMetadata", () => { + test("inserts $id/title/description right after $schema, ahead of the rest of the document", () => { + const document = { $schema: "https://json-schema.org/draft/2020-12/schema", type: "object" }; + + const result = withSchemaMetadata(document, { + id: "https://example.com/schema.json", + title: "Example", + description: "An example schema.", + }); + + expect(Object.keys(result)).toEqual(["$schema", "$id", "title", "description", "type"]); + expect(result).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.com/schema.json", + title: "Example", + description: "An example schema.", + type: "object", + }); + }); + + test("caller-supplied metadata wins over a conflicting $id/title/description already on the document", () => { + const document = { + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://stale.example.com/old-schema.json", + title: "Stale title", + description: "Stale description.", + type: "object", + }; + + const result = withSchemaMetadata(document, { + id: "https://example.com/schema.json", + title: "Example", + description: "An example schema.", + }); + + expect(Object.keys(result)).toEqual(["$schema", "$id", "title", "description", "type"]); + expect(result).toEqual({ + $schema: "https://json-schema.org/draft/2020-12/schema", + $id: "https://example.com/schema.json", + title: "Example", + description: "An example schema.", + type: "object", + }); + }); +}); diff --git a/packages/config/src/bun.ts b/packages/config/src/bun.ts index 979fef9209..dffb36ef32 100644 --- a/packages/config/src/bun.ts +++ b/packages/config/src/bun.ts @@ -7,12 +7,12 @@ const cliConfigIo: CliConfigIo = makeCliConfigIo( ); export const loadCliConfig = cliConfigIo.loadCliConfig; -export const findCliProjectRootFor = cliConfigIo.findCliProjectRootFor; -export const findCliProjectPathsFor = cliConfigIo.findCliProjectPathsFor; +export const findCliProjectRoot = cliConfigIo.findCliProjectRoot; +export const findCliProjectPaths = cliConfigIo.findCliProjectPaths; export const loadCliConfigFile = cliConfigIo.loadCliConfigFile; -export const loadCliProjectEnvironmentFor = cliConfigIo.loadCliProjectEnvironmentFor; +export const loadCliProjectEnvironment = cliConfigIo.loadCliProjectEnvironment; export const saveCliConfig = cliConfigIo.saveCliConfig; -export const loadFunctionsManifest = cliConfigIo.loadFunctionsManifest; +export const inferFunctionsManifest = cliConfigIo.inferFunctionsManifest; export type { CliConfigIo } from "./promise-facade.ts"; // Re-exports every pure symbol from `.` (types, schema, errors, etc.) so // `./io` consumers can name `LoadedCliConfig`/`CliProjectPaths`/etc. without diff --git a/packages/config/src/cli-config.service.ts b/packages/config/src/cli-config.service.ts index 9493751bdb..2820deadac 100644 --- a/packages/config/src/cli-config.service.ts +++ b/packages/config/src/cli-config.service.ts @@ -1,18 +1,42 @@ import type { Effect } from "effect"; import { Context } from "effect"; +import type { PlatformError } from "effect/PlatformError"; import type { LoadedCliConfig, LoadCliConfigOptions, SaveCliConfigOptions, } from "./config-document.ts"; +import type { + CliConfigParseError, + CliProjectEnvParseError, + DuplicateRemoteProjectIdError, + InvalidRemoteProjectIdError, +} from "./errors.ts"; + +/** + * Every error a `load`/`loadFile`/`save` rejection can carry: this package's + * own tagged failures (a malformed config document, a duplicate or + * malformed `[remotes.*]` block, a malformed `.env`/`.env.local` file) plus + * `PlatformError`, the single tagged wrapper Effect's `FileSystem` service + * uses for every host/OS failure (`effect/PlatformError`). A Promise-based + * consumer (`@supabase/config/io`) can distinguish these via `instanceof`. + */ +type CliConfigStoreError = + | CliConfigParseError + | DuplicateRemoteProjectIdError + | InvalidRemoteProjectIdError + | CliProjectEnvParseError + | PlatformError; interface CliConfigStoreShape { readonly load: ( cwd: string, options?: LoadCliConfigOptions, - ) => Effect.Effect; - readonly loadFile: (path: string) => Effect.Effect; - readonly save: (options: SaveCliConfigOptions) => Effect.Effect; + ) => Effect.Effect; + readonly loadFile: (path: string) => Effect.Effect; + readonly save: ( + options: SaveCliConfigOptions, + ) => Effect.Effect; } export class CliConfigStore extends Context.Service()( diff --git a/packages/config/src/config-document.ts b/packages/config/src/config-document.ts index 2cb90095bb..5718ae7777 100644 --- a/packages/config/src/config-document.ts +++ b/packages/config/src/config-document.ts @@ -71,7 +71,7 @@ export const cliConfigValueSourceAt = ( * duplicate-`project_id`/project-ref-format checks across every * `[remotes.*]` block (`config.go:594-602,996-1001`) run unconditionally on * every config load in Go, not only when a caller ends up selecting a - * remote — but here they only run when {@link LoadCliConfigOptions.goViperCompat} + * remote — but here they only run when {@link InternalLoadCliConfigOptions.goViperCompat} * is `true`, regardless of whether `projectRef` is set, so non-Go-parity * callers that never select a remote (and never opt into Go parity) aren't * broken by an unrelated duplicate/malformed `[remotes.*]` block. @@ -99,6 +99,13 @@ export interface LoadCliConfigOptions { * would never see. */ readonly tomlOnly?: boolean; +} + +/** + * Not covered by semver — exported from `@supabase/config/internal` only. See + * that module's header for why. + */ +export interface InternalLoadCliConfigOptions extends LoadCliConfigOptions { /** * Opt into the Go/viper-parity decode+validation semantics this loader * otherwise omits, so only the Go-parity legacy shell (and shared modules diff --git a/packages/config/src/effect.ts b/packages/config/src/effect.ts index b73e31107b..ac715ca58e 100644 --- a/packages/config/src/effect.ts +++ b/packages/config/src/effect.ts @@ -1,19 +1,61 @@ // Effect-native surface — superset of the default entrypoint. export * from "./index.ts"; -export { - configJsonPath, - configTomlPath, - loadCliConfig, - loadCliConfigFile, - saveCliConfig, -} from "./io.ts"; +import type { Effect } from "effect"; +import type { LoadCliConfigOptions } from "./config-document.ts"; +import type { ResolvedCliConfigValue } from "./lib/resolve.ts"; +import * as io from "./io.ts"; +import type { CliProjectEnvironment } from "./project.ts"; +import * as project from "./project.ts"; + +export { configJsonPath, configTomlPath, saveCliConfig } from "./io.ts"; + +/** + * Narrowed to the public `LoadCliConfigOptions` (no `goViperCompat`). The + * underlying implementation in `./io.ts` is typed against the wider + * `InternalLoadCliConfigOptions` (a strict superset — one additional optional + * field), so assigning it here is a safe, cast-free narrowing: a function + * accepting the wider options type is assignable to a variable typed to + * accept only the narrower one. `@supabase/config/internal` re-exports this + * same runtime function typed to additionally show `goViperCompat`. + */ +export const loadCliConfig: ( + cwd: string, + options?: LoadCliConfigOptions, +) => ReturnType = io.loadCliConfig; + +/** See {@link loadCliConfig}'s doc comment for the narrowing rationale. */ +export const loadCliConfigFile: ( + filePath: string, + options?: LoadCliConfigOptions, +) => ReturnType = io.loadCliConfigFile; + export { inferFunctionsManifest } from "./functions-manifest.ts"; -export { - loadDotEnvFile, - loadCliProjectEnvironment, - resolveCliConfigSubtree, - resolveCliConfigValue, -} from "./project.ts"; +export { loadDotEnvFile, loadCliProjectEnvironment } from "./project.ts"; + +/** + * Explicit named exports take precedence over `export * from "./index.ts"` + * above for a shared name (ESM re-export resolution), so these Effect-typed + * variants deliberately shadow `./index.ts`'s plain sync + * `resolveCliConfigValue`/`resolveCliConfigSubtree` on this subpath — the + * Effect-typed variant wins on `./effect`; the sync variant lives on `.`. + * + * Narrowed to no options parameter (no `goViperCompat`) for the same reason + * as {@link loadCliConfig} above; `@supabase/config/internal` re-exports + * these same runtime functions typed to additionally show `goViperCompat`. + */ +export const resolveCliConfigValue: ( + value: T, + cliProjectEnv: Pick, + configPath: string, +) => Effect.Effect> = project.resolveCliConfigValue; + +/** See {@link resolveCliConfigValue}'s doc comment for the shadowing and narrowing rationale. */ +export const resolveCliConfigSubtree: ( + value: T, + cliProjectEnv: Pick, + pathPrefix: string, +) => Effect.Effect> = project.resolveCliConfigSubtree; + export { findCliProjectPaths, findCliProjectRoot } from "./paths.ts"; export { cliConfigStoreLayer } from "./cli-config.layer.ts"; export { CliConfigStore } from "./cli-config.service.ts"; diff --git a/packages/config/src/entrypoint-purity.unit.test.ts b/packages/config/src/entrypoint-purity.unit.test.ts index 696c7d0b4e..45614165a4 100644 --- a/packages/config/src/entrypoint-purity.unit.test.ts +++ b/packages/config/src/entrypoint-purity.unit.test.ts @@ -5,6 +5,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import * as defaultEntrypoint from "./index.ts"; import * as effectEntrypoint from "./effect.ts"; +import * as internalEntrypoint from "./internal.ts"; // `src/index.ts` is the entrypoint Studio (a browser bundle) imports // directly. It must stay bundlable with no Node/Bun runtime underneath it — @@ -18,12 +19,31 @@ import * as effectEntrypoint from "./effect.ts"; const srcDir = dirname(fileURLToPath(import.meta.url)); const packageRoot = join(srcDir, ".."); + +interface DistConditions { + readonly types: string; + readonly default: string; +} + +interface TypesBunDefaultExport { + readonly types: string; + readonly bun: string; + readonly default: string; +} + const packageJson = JSON.parse(readFileSync(join(packageRoot, "package.json"), "utf8")) as { readonly exports: { - readonly ".": string; - readonly "./io": Readonly>; - readonly "./effect": string; + readonly ".": TypesBunDefaultExport; + readonly "./internal": TypesBunDefaultExport; + readonly "./io": { + readonly bun: string; + readonly node: DistConditions; + readonly browser: DistConditions; + readonly default: DistConditions; + }; + readonly "./effect": TypesBunDefaultExport; readonly "./schema.json": string; + readonly "./project-schema.json": string; }; }; @@ -280,12 +300,14 @@ const expectedPureGraphFiles = [ "functions-manifest-model.ts", "sparse.ts", "schema-metadata.ts", - "tls.ts", "lib/env.ts", + "lib/resolve.ts", "lib/schema.ts", "lib/secret-paths.ts", "project-config/api-attributes.ts", + "project-config/hosted-sections.ts", "project-config/project-config.ts", + "project-config/project-schema.ts", "project-config/registry-auth.ts", "project-config/registry-row.ts", "project-config/registry.ts", @@ -334,21 +356,50 @@ describe("src/index.ts stays browser-safe", () => { }); }); +// CLI-2234 group 8c: `src/io-browser.ts` (the `browser` condition target for +// `@supabase/config/io`) must stay just as bundler-safe as `index.ts` itself +// — it only adds inert, throw-when-invoked stubs plus a type-only import from +// `promise-facade.ts` (erased at the specifier-scan level, same as every +// other `import type`/`export type` statement this walker already ignores) +// on top of `export * from "./index.ts"`. Reuses the exact same walker and +// browser-safe bare-specifier allowlist as the `index.ts` suite above. +const ioBrowserGraph = collectImportGraph(join(srcDir, "io-browser.ts")); +const expectedIoBrowserGraphFiles = [ + join(srcDir, "io-browser.ts"), + ...expectedPureGraphFiles, +].sort(); + +describe("src/io-browser.ts stays browser-safe", () => { + test("the traversal actually walked the real module graph", () => { + expect(ioBrowserGraph.visitedFiles.size).toBeGreaterThan(1); + expect(ioBrowserGraph.bareSpecifiers.has("effect")).toBe(true); + }); + + test("every bare import reachable from io-browser.ts is on the browser-safe allowlist", () => { + const disallowed = [...ioBrowserGraph.bareSpecifiers].filter( + (specifier) => !allowedBareSpecifier(specifier), + ); + expect(disallowed).toEqual([]); + }); + + test("the pure runtime graph is exactly index.ts's graph plus io-browser.ts itself", () => { + expect([...ioBrowserGraph.visitedFiles].sort()).toEqual(expectedIoBrowserGraphFiles); + }); +}); + describe("src/index.ts export surface", () => { test("pins the exact set of runtime export names", () => { expect(Object.keys(defaultEntrypoint).sort()).toMatchInlineSnapshot(` [ - "AUTH_HOOK_NAMES", "CLI_CONFIG_SCHEMA_URL", "CliConfigParseError", "CliConfigSchema", "CliProjectEnvParseError", "DuplicateRemoteProjectIdError", - "ENV_CAPTURE_REGEX", "InvalidRemoteProjectIdError", - "KONG_LOCAL_CA_CERT", - "MissingCliConfigValueError", + "PROJECT_CONFIG_SCHEMA_URL", "ProjectConfigParseError", + "ProjectConfigSchema", "attachApiResponse", "cliConfigValueSourceAt", "comparableProjectConfigPaths", @@ -362,12 +413,13 @@ describe("src/index.ts export surface", () => { "getDefaultCliConfig", "isComparableProjectConfigPath", "omitDefaultValues", - "projectConfigMappingRows", + "resolveCliConfigSubtree", + "resolveCliConfigValue", "subtractCliConfig", "toCliConfigJsonSchema", "toProjectConfig", + "toProjectConfigJsonSchema", "unmappedApiFields", - "unmappedSecretApiPaths", ] `); }); @@ -377,18 +429,16 @@ describe("src/effect.ts is a superset of src/index.ts", () => { test("pins the exact set of runtime export names", () => { expect(Object.keys(effectEntrypoint).sort()).toMatchInlineSnapshot(` [ - "AUTH_HOOK_NAMES", "CLI_CONFIG_SCHEMA_URL", "CliConfigParseError", "CliConfigSchema", "CliConfigStore", "CliProjectEnvParseError", "DuplicateRemoteProjectIdError", - "ENV_CAPTURE_REGEX", "InvalidRemoteProjectIdError", - "KONG_LOCAL_CA_CERT", - "MissingCliConfigValueError", + "PROJECT_CONFIG_SCHEMA_URL", "ProjectConfigParseError", + "ProjectConfigSchema", "attachApiResponse", "cliConfigStoreLayer", "cliConfigValueSourceAt", @@ -412,20 +462,25 @@ describe("src/effect.ts is a superset of src/index.ts", () => { "loadCliProjectEnvironment", "loadDotEnvFile", "omitDefaultValues", - "projectConfigMappingRows", "resolveCliConfigSubtree", "resolveCliConfigValue", "saveCliConfig", "subtractCliConfig", "toCliConfigJsonSchema", "toProjectConfig", + "toProjectConfigJsonSchema", "unmappedApiFields", - "unmappedSecretApiPaths", ] `); }); - test("every runtime export key of index.ts is also exported by effect.ts, with an identical (not shadowed) binding", () => { + // `resolveCliConfigValue`/`resolveCliConfigSubtree` are the one deliberate + // exception (see `effect.ts`'s doc comment): `./effect`'s Effect-typed + // variant intentionally shadows `./index.ts`'s plain sync variant, since + // explicit named exports win over a star re-export of the same name. + const deliberatelyShadowedKeys = new Set(["resolveCliConfigValue", "resolveCliConfigSubtree"]); + + test("every runtime export key of index.ts is also exported by effect.ts, identically bound except the deliberately shadowed resolve helpers", () => { const defaultKeys = Object.keys(defaultEntrypoint); // Guards against both namespace objects being empty due to a broken @@ -438,31 +493,105 @@ describe("src/effect.ts is a superset of src/index.ts", () => { } const defaultValue = (defaultEntrypoint as Record)[key]; const effectValue = (effectEntrypoint as Record)[key]; - return effectValue === defaultValue ? [] : [`mismatched (shadowed): ${key}`]; + const identical = effectValue === defaultValue; + if (deliberatelyShadowedKeys.has(key)) { + return identical + ? [`expected ${key} to be shadowed on ./effect, but it was identical`] + : []; + } + return identical ? [] : [`mismatched (shadowed): ${key}`]; }); expect(mismatches).toEqual([]); }); }); +describe("src/internal.ts export surface", () => { + test("pins the exact set of runtime export names", () => { + expect(Object.keys(internalEntrypoint).sort()).toMatchInlineSnapshot(` + [ + "AUTH_HOOK_NAMES", + "ENV_CAPTURE_REGEX", + "loadCliConfig", + "projectConfigMappingRows", + "resolveCliConfigSubtree", + "resolveCliConfigValue", + "unmappedSecretApiPaths", + ] + `); + }); + + // `./internal`'s `resolveCliConfigValue`/`resolveCliConfigSubtree`/ + // `loadCliConfig` are the SAME runtime functions `./effect` exports + // (only the accepted options TYPE differs — internal.ts's is the wider, + // `goViperCompat`-capable one), so unlike the deliberate shadowing between + // `.` and `./effect` above, there is no shadowing to assert here. + test("resolveCliConfigValue, resolveCliConfigSubtree, and loadCliConfig are identical to effect.ts's bindings", () => { + for (const key of [ + "resolveCliConfigValue", + "resolveCliConfigSubtree", + "loadCliConfig", + ] as const) { + expect((internalEntrypoint as Record)[key]).toBe( + (effectEntrypoint as Record)[key], + ); + } + }); +}); + describe("package.json exports map", () => { test("./io exposes exactly the bun/node/browser/default conditions, in that order", () => { const ioExports = packageJson.exports["./io"]; expect(Object.keys(ioExports)).toEqual(["bun", "node", "browser", "default"]); }); - test("every ./io condition target file exists on disk", () => { - const ioExports = packageJson.exports["./io"]; - for (const target of Object.values(ioExports)) { - expect(() => readFileSync(join(packageRoot, target))).not.toThrow(); + // `.`/`./effect`/`./internal` lead with `bun` (CLI-2234): `tsc` under this + // repo's `customConditions: ["bun"]` must resolve straight to `src/*.ts` + // (self-typed, no separate `.d.ts` needed) instead of `dist/*.d.ts`, which + // requires `bun` to win the exports-map lookup ahead of `types` — see + // `apps/cli/tsconfig.json`'s `customConditions`. `types` only needs to + // precede `default` (the dist JS the `types` `.d.ts` describes), not be + // first outright, so a plain `nodenext` consumer (no `bun` condition + // requested) still resolves `types` -> `dist/*.d.ts` correctly. + test("'types' precedes 'default' in every conditional export object (CLI-2234)", () => { + const conditionObjects = [ + packageJson.exports["."], + packageJson.exports["./effect"], + packageJson.exports["./internal"], + packageJson.exports["./io"].node, + packageJson.exports["./io"].browser, + packageJson.exports["./io"].default, + ]; + for (const conditions of conditionObjects) { + const keys = Object.keys(conditions); + expect(keys.indexOf("types")).toBeLessThan(keys.indexOf("default")); } }); - test("the '.' and './effect' export targets exist on disk", () => { - // `./schema.json` is a build output (`dist/schema.json`) and intentionally - // skipped here — it only exists after running `pnpm run build`. - for (const key of [".", "./effect"] as const) { - const target = packageJson.exports[key]; + test("'.', './effect', and './internal' lead with the 'bun' condition (CLI-2234)", () => { + for (const key of [".", "./effect", "./internal"] as const) { + expect(Object.keys(packageJson.exports[key])[0]).toBe("bun"); + } + }); + + test("pins the exact top-level exports-map subpath set", () => { + expect(Object.keys(packageJson.exports).sort()).toEqual( + [".", "./internal", "./io", "./effect", "./schema.json", "./project-schema.json"].sort(), + ); + }); + + // The `types`/`default` conditions of `.`/`./effect`/`./internal`/`./io` + // (node, browser, default) all point at `dist/` build outputs, which only + // exist after `pnpm run build` — intentionally NOT checked here so this + // test stays build-independent. `scripts/build.ts`'s tree-shake/Node-consumer + // smoke test owns dist correctness instead (CLI-2232). + test("the ./io bun condition target exists on disk (its only src target)", () => { + expect(() => readFileSync(join(packageRoot, packageJson.exports["./io"].bun))).not.toThrow(); + }); + + test("the '.', './effect', and './internal' bun condition targets exist on disk", () => { + for (const key of [".", "./effect", "./internal"] as const) { + const target = packageJson.exports[key].bun; expect(() => readFileSync(join(packageRoot, target))).not.toThrow(); } }); diff --git a/packages/config/src/errors.ts b/packages/config/src/errors.ts index be2bdedbac..c235448665 100644 --- a/packages/config/src/errors.ts +++ b/packages/config/src/errors.ts @@ -134,10 +134,6 @@ export class CliProjectEnvParseError extends Data.TaggedError("CliProjectEnvPars readonly line: number; }> {} -export class MissingCliConfigValueError extends Data.TaggedError("MissingCliConfigValueError")<{ - readonly configPath: string; -}> {} - /** * Two `[remotes.*]` blocks declare the same `project_id` as the requested * `projectRef`. Mirrors Go's `loadFromFile` guard diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index ce27b13fdb..d5d5bc6cbf 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -17,7 +17,6 @@ export { CliProjectEnvParseError, DuplicateRemoteProjectIdError, InvalidRemoteProjectIdError, - MissingCliConfigValueError, ProjectConfigParseError, } from "./errors.ts"; export type { ConfigFormat } from "./config-format.ts"; @@ -38,14 +37,14 @@ export { type FunctionsManifest, type ResolvedFunctionConfig, } from "./functions-manifest-model.ts"; -export type { - LoadCliProjectEnvironmentOptions, - CliProjectEnvironment, - ResolvedCliConfigValue, - ResolveCliConfigOptions, -} from "./project.ts"; +export type { LoadCliProjectEnvironmentOptions, CliProjectEnvironment } from "./project.ts"; +export { + type ResolvedCliConfigValue, + resolveCliConfigValue, + resolveCliConfigSubtree, +} from "./lib/resolve.ts"; export type { CliProjectPaths } from "./paths.ts"; -export { CLI_CONFIG_SCHEMA_URL } from "./schema-metadata.ts"; +export { CLI_CONFIG_SCHEMA_URL, PROJECT_CONFIG_SCHEMA_URL } from "./schema-metadata.ts"; export { type EffectiveConfig, type SparseCliConfig, @@ -53,8 +52,6 @@ export { omitDefaultValues, subtractCliConfig, } from "./sparse.ts"; -export { KONG_LOCAL_CA_CERT } from "./tls.ts"; -export { ENV_CAPTURE_REGEX } from "./lib/env.ts"; export { type CliConfigWithRawPresence, type ProjectConfig, @@ -68,7 +65,4 @@ export { toProjectConfig, unmappedApiFields, } from "./project-config/project-config.ts"; -export { type ProjectConfigApiAttributes } from "./project-config/api-attributes.ts"; -export { type ProjectConfigMappingRow } from "./project-config/registry-row.ts"; -export { projectConfigMappingRows } from "./project-config/registry.ts"; -export { AUTH_HOOK_NAMES, unmappedSecretApiPaths } from "./project-config/registry-auth.ts"; +export { ProjectConfigSchema, toProjectConfigJsonSchema } from "./project-config/project-schema.ts"; diff --git a/packages/config/src/internal.ts b/packages/config/src/internal.ts new file mode 100644 index 0000000000..8734c5ecff --- /dev/null +++ b/packages/config/src/internal.ts @@ -0,0 +1,25 @@ +/** + * NOT covered by semver. This subpath exists solely for `apps/cli`'s own use + * and its contract-guard tests — every export here (its existence, its shape, + * its behavior) can change or vanish in any release without notice. External + * consumers must use `.`, `./effect`, or `./io` instead; only `apps/cli` may + * import `@supabase/config/internal` (enforced by + * `src/monorepo-import-contract.unit.test.ts`). + * + * `loadCliConfig`/`resolveCliConfigValue`/`resolveCliConfigSubtree` below are + * the SAME runtime functions `./effect` exports, just re-typed here to widen + * their options parameter to the internal-only, Go-parity `goViperCompat` + * knob (`InternalLoadCliConfigOptions` for `loadCliConfig`; + * `resolveCliConfigValue`/`resolveCliConfigSubtree`'s own widened options + * type is package-internal and not itself re-exported here) — this module + * otherwise only re-exports types and registry data, not independent + * implementations. + */ +export { ENV_CAPTURE_REGEX } from "./lib/env.ts"; +export { AUTH_HOOK_NAMES, unmappedSecretApiPaths } from "./project-config/registry-auth.ts"; +export { projectConfigMappingRows } from "./project-config/registry.ts"; +export { type ProjectConfigMappingRow } from "./project-config/registry-row.ts"; +export { type ProjectConfigApiAttributes } from "./project-config/api-attributes.ts"; +export { type InternalLoadCliConfigOptions } from "./config-document.ts"; +export { resolveCliConfigValue, resolveCliConfigSubtree } from "./project.ts"; +export { loadCliConfig } from "./io.ts"; diff --git a/packages/config/src/io-browser.ts b/packages/config/src/io-browser.ts index 4d5918832d..66ea9bf9b0 100644 --- a/packages/config/src/io-browser.ts +++ b/packages/config/src/io-browser.ts @@ -23,21 +23,21 @@ async function unavailableInBrowser(): Promise { // the real facades' export shape. const cliConfigIo: CliConfigIo = { loadCliConfig: unavailableInBrowser, - findCliProjectRootFor: unavailableInBrowser, - findCliProjectPathsFor: unavailableInBrowser, + findCliProjectRoot: unavailableInBrowser, + findCliProjectPaths: unavailableInBrowser, loadCliConfigFile: unavailableInBrowser, - loadCliProjectEnvironmentFor: unavailableInBrowser, + loadCliProjectEnvironment: unavailableInBrowser, saveCliConfig: unavailableInBrowser, - loadFunctionsManifest: unavailableInBrowser, + inferFunctionsManifest: unavailableInBrowser, }; export const loadCliConfig = cliConfigIo.loadCliConfig; -export const findCliProjectRootFor = cliConfigIo.findCliProjectRootFor; -export const findCliProjectPathsFor = cliConfigIo.findCliProjectPathsFor; +export const findCliProjectRoot = cliConfigIo.findCliProjectRoot; +export const findCliProjectPaths = cliConfigIo.findCliProjectPaths; export const loadCliConfigFile = cliConfigIo.loadCliConfigFile; -export const loadCliProjectEnvironmentFor = cliConfigIo.loadCliProjectEnvironmentFor; +export const loadCliProjectEnvironment = cliConfigIo.loadCliProjectEnvironment; export const saveCliConfig = cliConfigIo.saveCliConfig; -export const loadFunctionsManifest = cliConfigIo.loadFunctionsManifest; +export const inferFunctionsManifest = cliConfigIo.inferFunctionsManifest; export type { CliConfigIo } from "./promise-facade.ts"; // Re-exports every pure symbol from `.` (types, schema, errors, etc.) so // `./io` consumers can name `LoadedCliConfig`/`CliProjectPaths`/etc. without diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index c8fb9a9d8d..30f2c1e6db 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -6,7 +6,7 @@ import { encodeCliConfigToTomlDocument, isObject, type LoadedCliConfig, - type LoadCliConfigOptions, + type InternalLoadCliConfigOptions, cliConfigSchemaKey, type CliConfigValueSource, type SaveCliConfigOptions, @@ -482,7 +482,7 @@ export const configTomlPath = Effect.fnUntraced(function* (cwd: string) { export const loadCliConfigFile = Effect.fnUntraced(function* ( filePath: string, - options?: LoadCliConfigOptions, + options?: InternalLoadCliConfigOptions, ) { const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; @@ -640,7 +640,7 @@ export const loadCliConfigFile = Effect.fnUntraced(function* ( export const loadCliConfig = Effect.fnUntraced(function* ( cwd: string, - options?: LoadCliConfigOptions, + options?: InternalLoadCliConfigOptions, ) { const fs = yield* FileSystem.FileSystem; const project = yield* findCliProjectPaths(cwd, { search: options?.search }); diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index 89b20a451c..43cd8dd257 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -13,7 +13,7 @@ import { encodeCliConfigToToml, cliConfigValueSourceAt, type LoadedCliConfig, - type LoadCliConfigOptions, + type InternalLoadCliConfigOptions, } from "./config-document.ts"; import { configJsonPath, @@ -2173,7 +2173,7 @@ describe("config io deprecated [auth.external.{linkedin,slack}] back-compat", () errorSpy = undefined; }); - async function loadToml(contents: string, options?: LoadCliConfigOptions) { + async function loadToml(contents: string, options?: InternalLoadCliConfigOptions) { const cwd = makeTempProject(); const path = await runConfigEffect(configTomlPath(cwd)); await mkdir(join(cwd, "supabase"), { recursive: true }); diff --git a/packages/config/src/lib/resolve.ts b/packages/config/src/lib/resolve.ts new file mode 100644 index 0000000000..fac62b90f9 --- /dev/null +++ b/packages/config/src/lib/resolve.ts @@ -0,0 +1,170 @@ +import { Redacted } from "effect"; +import { isEnvReference, ENV_CAPTURE_REGEX, ENV_CAPTURE_REGEX_STRICT } from "./env.ts"; +import { isSecretPath } from "./secret-paths.ts"; +import type { CliProjectEnvironment } from "../project.ts"; + +type ResolvedString = string | Redacted.Redacted; + +export type ResolvedCliConfigValue = T extends string + ? ResolvedString + : T extends ReadonlyArray + ? ReadonlyArray> + : T extends Array + ? Array> + : T extends Record + ? { readonly [K in keyof T]: ResolvedCliConfigValue } & { + readonly [key: string]: ResolvedCliConfigValue; + } + : T extends object + ? { readonly [K in keyof T]: ResolvedCliConfigValue } + : T; + +export function toPathSegments(path: string): ReadonlyArray { + if (path === "") { + return []; + } + + return path.split(".").filter((segment) => segment.length > 0); +} + +function interpolateLeafValue( + value: string, + env: Readonly>, + goViperCompat: boolean, +): string { + const match = (goViperCompat ? ENV_CAPTURE_REGEX : ENV_CAPTURE_REGEX_STRICT).exec(value); + const envName = match?.[1]; + + if (envName === undefined) { + return value; + } + + const resolved = env[envName]; + // Preserve the literal `env(VAR)` verbatim when VAR is unset OR present but + // empty (e.g. a dotenv `KEY=` line). Matches Go's `LoadEnvHook` + // (`apps/cli-go/pkg/config/decode_hooks.go:19-24`: `len(env) > 0`), which + // only substitutes a non-empty value — same gate as `substituteEnvLeaf` in + // `./env.ts`. Without this, a present-but-empty `env(...)` secret (e.g. + // `edge_runtime.secrets.FOO = "env(EMPTY)"`) resolves to `""` here, gets + // redacted by `redactValue` as a real value instead of skipped as an + // unresolved literal, and `secrets set` uploads a blank secret Go would + // never send. + if (resolved === undefined || resolved === "") { + return value; + } + + return resolved; +} + +function interpolateValue( + value: unknown, + env: Readonly>, + goViperCompat: boolean, +): unknown { + if (Array.isArray(value)) { + return value.map((item) => interpolateValue(item, env, goViperCompat)); + } + + if (typeof value === "object" && value !== null) { + const result: Record = {}; + + for (const [key, child] of Object.entries(value)) { + result[key] = interpolateValue(child, env, goViperCompat); + } + + return result; + } + + if (typeof value === "string") { + return interpolateLeafValue(value, env, goViperCompat); + } + + return value; +} + +function redactValue(value: unknown, path: ReadonlyArray, goViperCompat: boolean): unknown { + if (Array.isArray(value)) { + return value.map((item, index) => redactValue(item, [...path, String(index)], goViperCompat)); + } + + if (typeof value === "object" && value !== null) { + const result: Record = {}; + + for (const [key, child] of Object.entries(value)) { + result[key] = redactValue(child, [...path, key], goViperCompat); + } + + return result; + } + + if (typeof value === "string" && isSecretPath(path) && !isEnvReference(value, goViperCompat)) { + return Redacted.make(value, { label: path.join(".") }); + } + + return value; +} + +/** + * Shared by the plain sync resolvers below and `../project.ts`'s + * Effect-typed `resolveCliConfigValue`/`resolveCliConfigSubtree` (which wrap + * this in `Effect.sync` and additionally accept the internal-only + * `goViperCompat` option). + * + * Declared as an overload pair rather than a single generic signature: the + * body's `unknown`-typed implementation signature is what lets + * `interpolateValue`/`redactValue` (both genuinely `unknown -> unknown`, + * since the recursion branches on runtime shape, not on `T`) flow straight + * through to the return without an `as` cast — callers only ever see the + * generic overload above, which resolves `T` from the argument and returns + * `ResolvedCliConfigValue` directly. + */ +export function resolveCliConfigValueAtPath( + value: T, + cliProjectEnv: Pick, + path: ReadonlyArray, + goViperCompat: boolean, +): ResolvedCliConfigValue; +export function resolveCliConfigValueAtPath( + value: unknown, + cliProjectEnv: Pick, + path: ReadonlyArray, + goViperCompat: boolean, +): unknown { + const interpolated = interpolateValue(value, cliProjectEnv.values, goViperCompat); + return redactValue(interpolated, path, goViperCompat); +} + +/** + * Plain synchronous counterpart of `../project.ts`'s Effect-typed + * `resolveCliConfigValue`, exported from `.` under the same name — `./effect` + * re-exports the Effect-typed variant explicitly, which wins over this one's + * star re-export through `./index.ts` (see `../effect.ts`'s doc comment). + * + * `cliProjectEnv` only needs `.values` (`Pick`) — + * a caller that already has a project's env values but not the full + * `CliProjectEnvironment` shape (e.g. `paths`/`loadedPaths`/`sources`) can pass + * `{ values }` directly instead of threading through the whole loaded object. + * + * Has no options parameter: this package's one resolver knob (`goViperCompat`) + * is internal-only — see `InternalResolveCliConfigOptions` in `../project.ts`. + * That type is package-internal (not itself re-exported from + * `@supabase/config/internal`); only the `resolveCliConfigValue`/ + * `resolveCliConfigSubtree` functions widened to accept it are exported from + * there. Adding a public knob later is a non-breaking, additive change. + */ +export function resolveCliConfigValue( + value: T, + cliProjectEnv: Pick, + configPath: string, +): ResolvedCliConfigValue { + return resolveCliConfigValueAtPath(value, cliProjectEnv, toPathSegments(configPath), false); +} + +/** See {@link resolveCliConfigValue}'s doc comment for why `cliProjectEnv` only needs `.values`. */ +export function resolveCliConfigSubtree( + value: T, + cliProjectEnv: Pick, + pathPrefix: string, +): ResolvedCliConfigValue { + return resolveCliConfigValueAtPath(value, cliProjectEnv, toPathSegments(pathPrefix), false); +} diff --git a/packages/config/src/lib/resolve.unit.test.ts b/packages/config/src/lib/resolve.unit.test.ts new file mode 100644 index 0000000000..b2765ca3aa --- /dev/null +++ b/packages/config/src/lib/resolve.unit.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, test } from "vitest"; +import { Redacted } from "effect"; +import { resolveCliConfigValue, resolveCliConfigSubtree } from "../index.ts"; + +// Behavioral coverage of the two public sync resolvers, imported from the +// public `.` entrypoint (not `./resolve.ts` directly) — this is the exact +// surface an external consumer sees, options param removed (CLI-2234). + +describe("resolveCliConfigValue", () => { + test("a plain leaf passes through unchanged", () => { + expect(resolveCliConfigValue("hello", { values: {} }, "some.path")).toBe("hello"); + }); + + test("an env(NAME) reference resolves from the supplied values", () => { + expect(resolveCliConfigValue("env(FOO)", { values: { FOO: "bar" } }, "some.path")).toBe("bar"); + }); + + test("an unresolved env(NAME) reference is preserved verbatim", () => { + expect(resolveCliConfigValue("env(FOO)", { values: {} }, "some.path")).toBe("env(FOO)"); + }); + + test("a value at a schema-known secret path resolves and is wrapped in Redacted", () => { + const resolved = resolveCliConfigValue( + "env(OPENAI_API_KEY)", + { values: { OPENAI_API_KEY: "sk-test" } }, + "studio.openai_api_key", + ); + + expect(Redacted.isRedacted(resolved)).toBe(true); + if (Redacted.isRedacted(resolved)) { + expect(Redacted.value(resolved)).toBe("sk-test"); + } + }); +}); + +describe("resolveCliConfigSubtree", () => { + test("resolves and redacts nested leaves under a path prefix", () => { + const resolved = resolveCliConfigSubtree( + { openai_api_key: "env(OPENAI_API_KEY)", api_url: "http://127.0.0.1" }, + { values: { OPENAI_API_KEY: "sk-test" } }, + "studio", + ); + + expect(resolved.api_url).toBe("http://127.0.0.1"); + expect(Redacted.isRedacted(resolved.openai_api_key)).toBe(true); + if (Redacted.isRedacted(resolved.openai_api_key)) { + expect(Redacted.value(resolved.openai_api_key)).toBe("sk-test"); + } + }); +}); diff --git a/packages/config/src/monorepo-import-contract.unit.test.ts b/packages/config/src/monorepo-import-contract.unit.test.ts index 87bbabe479..204bc5ba7b 100644 --- a/packages/config/src/monorepo-import-contract.unit.test.ts +++ b/packages/config/src/monorepo-import-contract.unit.test.ts @@ -1,20 +1,23 @@ import { describe, expect, test } from "vitest"; import { readdirSync, readFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { dirname, join, sep } from "node:path"; import { fileURLToPath } from "node:url"; -// Enforces the two monorepo-wide import rules from `packages/config/AGENTS.md` +// Enforces the three monorepo-wide import rules from `packages/config/AGENTS.md` // ("Monorepo import rule"): `@supabase/config/io` has zero internal // consumers by design (it exists only for external, non-Effect-native -// Node/Bun code), and this package's internals must never be deep-imported -// (only the `.`/`./io`/`./effect` entrypoints are supported import paths). +// Node/Bun code), this package's internals must never be deep-imported (only +// the `.`/`./io`/`./effect`/`./internal` entrypoints are supported import +// paths), and `@supabase/config/internal` — unlike `./io` — IS an expected +// consumer, but only from `apps/cli`. // // A plain substring scan (no parsing) is enough for this — it's fast and the -// two forbidden specifiers can't appear by accident. The forbidden strings -// below are built by concatenation so this file's own source can never -// self-match (on top of the directory exclusion below, which already keeps -// this package's `src/` — where those specifier strings legitimately appear -// in test fixtures — out of the walk). +// forbidden specifiers can't appear by accident. The forbidden strings below +// are built by concatenation so this file's own source can never self-match +// (on top of the directory exclusion below, which already keeps this whole +// package — where those specifier strings legitimately appear in doc +// comments and the build script's own smoke-test source string — out of the +// walk). // const srcDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = join(srcDir, "..", "..", ".."); @@ -22,16 +25,18 @@ const repoRoot = join(srcDir, "..", "..", ".."); const configPackageName = ["@supabase", "config"].join("/"); const forbiddenIoSpecifier = `${configPackageName}/io`; const forbiddenDeepImportPrefix = `${configPackageName}/src/`; +const internalSpecifier = `${configPackageName}/internal`; +const allowedInternalConsumerPrefix = `${join(repoRoot, "apps", "cli")}${sep}`; const EXCLUDED_DIR_NAMES = new Set(["node_modules", "dist", ".repos"]); -const thisPackageSrcDir = srcDir; +const thisPackageDir = join(srcDir, ".."); function collectTsFiles(dir: string, into: string[]): void { for (const entry of readdirSync(dir, { withFileTypes: true })) { const fullPath = join(dir, entry.name); if (entry.isDirectory()) { - if (EXCLUDED_DIR_NAMES.has(entry.name) || fullPath === thisPackageSrcDir) { + if (EXCLUDED_DIR_NAMES.has(entry.name) || fullPath === thisPackageDir) { continue; } collectTsFiles(fullPath, into); @@ -58,4 +63,11 @@ describe("monorepo import contract for @supabase/config", () => { test("no file outside this package deep-imports @supabase/config/src/*", () => { expect(findViolations(forbiddenDeepImportPrefix)).toEqual([]); }); + + test("every @supabase/config/internal import outside this package is under apps/cli/", () => { + const violations = findViolations(internalSpecifier).filter( + (file) => !file.startsWith(allowedInternalConsumerPrefix), + ); + expect(violations).toEqual([]); + }); }); diff --git a/packages/config/src/node.ts b/packages/config/src/node.ts index 5364151d6e..c1cfe4e695 100644 --- a/packages/config/src/node.ts +++ b/packages/config/src/node.ts @@ -7,12 +7,12 @@ const cliConfigIo: CliConfigIo = makeCliConfigIo( ); export const loadCliConfig = cliConfigIo.loadCliConfig; -export const findCliProjectRootFor = cliConfigIo.findCliProjectRootFor; -export const findCliProjectPathsFor = cliConfigIo.findCliProjectPathsFor; +export const findCliProjectRoot = cliConfigIo.findCliProjectRoot; +export const findCliProjectPaths = cliConfigIo.findCliProjectPaths; export const loadCliConfigFile = cliConfigIo.loadCliConfigFile; -export const loadCliProjectEnvironmentFor = cliConfigIo.loadCliProjectEnvironmentFor; +export const loadCliProjectEnvironment = cliConfigIo.loadCliProjectEnvironment; export const saveCliConfig = cliConfigIo.saveCliConfig; -export const loadFunctionsManifest = cliConfigIo.loadFunctionsManifest; +export const inferFunctionsManifest = cliConfigIo.inferFunctionsManifest; export type { CliConfigIo } from "./promise-facade.ts"; // Re-exports every pure symbol from `.` (types, schema, errors, etc.) so // `./io` consumers can name `LoadedCliConfig`/`CliProjectPaths`/etc. without diff --git a/packages/config/src/project-config/hosted-sections.ts b/packages/config/src/project-config/hosted-sections.ts new file mode 100644 index 0000000000..f83d228b2c --- /dev/null +++ b/packages/config/src/project-config/hosted-sections.ts @@ -0,0 +1,21 @@ +/** + * The seven {@link CliConfig} (`../base.ts`) section keys a hosted + * project-config API response can speak for — the vocabulary ceiling for + * {@link ProjectConfig} (`./project-config.ts`)'s compile-time type and + * {@link ProjectConfigSchema} (`./project-schema.ts`)'s runtime derivation. + * Owned here rather than duplicated in either consumer, per this repo's + * policy of moving a shared constant to its correct owner instead of + * hand-keeping two copies in sync. + */ +export const HOSTED_SECTION_KEYS = [ + "api", + "auth", + "db", + "realtime", + "storage", + "workers", + "experimental", +] as const; + +/** The seven keys {@link ProjectConfig}/{@link ProjectConfigSchema} can carry. */ +export type HostedSectionKey = (typeof HOSTED_SECTION_KEYS)[number]; diff --git a/packages/config/src/project-config/project-config.ts b/packages/config/src/project-config/project-config.ts index d03fb6d1c3..5eb0a43a53 100644 --- a/packages/config/src/project-config/project-config.ts +++ b/packages/config/src/project-config/project-config.ts @@ -12,23 +12,11 @@ import { ProjectConfigApiAttributesSchema, type ProjectConfigApiAttributes, } from "./api-attributes.ts"; +import { HOSTED_SECTION_KEYS, type HostedSectionKey } from "./hosted-sections.ts"; import { AUTH_HOOK_NAMES, unmappedSecretApiPaths } from "./registry-auth.ts"; import { expectString } from "./registry-row.ts"; import { projectConfigMappingRows } from "./registry.ts"; -const HOSTED_SECTION_KEYS = [ - "api", - "auth", - "db", - "realtime", - "storage", - "workers", - "experimental", -] as const; - -/** The seven keys {@link ProjectConfig} can carry, derived once so the type and the runtime walk below can't drift apart. */ -type HostedSectionKey = (typeof HOSTED_SECTION_KEYS)[number]; - /** * A deeply-readonly JSON value — the shape of everything under * `_apiResponse`, which holds (a clone of) a parsed Management API JSON diff --git a/packages/config/src/project-config/project-schema.ts b/packages/config/src/project-config/project-schema.ts new file mode 100644 index 0000000000..e3f53a1ef2 --- /dev/null +++ b/packages/config/src/project-config/project-schema.ts @@ -0,0 +1,282 @@ +/** + * Runtime companion to {@link ProjectConfig} (`./project-config.ts`) — a + * schema that VALIDATES the same sparse hosted-section overlay + * `ProjectConfig` only describes at compile time. Derived from + * {@link CliConfigSchema} (`../base.ts`), never hand-declared, so the two can + * never independently drift: every leaf type, annotation, and leaf-level + * check traces back to the exact schema `base.ts` decodes a config document + * with. + * + * Derivation, in order: + * + * 1. {@link hostedSectionsStruct} picks the seven {@link HOSTED_SECTION_KEYS} + * fields off `CliConfigSchema.fields` and rebuilds a fresh `Schema.Struct` + * from them — the same field schemas `CliConfigSchema` itself embeds, not + * copies. + * 2. `SchemaAST.toType` strips every encoding/transformation (decoding + * defaults, `env()` deferred substitution, …), leaving the DECODED shape — + * exactly what `ProjectConfig` describes; a `ProjectConfig` value is never + * re-encoded. + * 3. {@link toDeepOptionalHostedAst} then recursively rebuilds the result: + * - In every `Objects` node (struct OR record), drops any + * `PropertySignature`/`IndexSignature` whose value AST carries the + * `x-secret` annotation (ADR 0019 rule 5 — `fromConfigDocument`/ + * `fromApiProjectConfig` never populate a secret leaf either), the same + * detection `../lib/secret-paths.ts`'s own walk uses. When that + * stripping empties out an `Objects` node that ORIGINALLY had at least + * one property/index signature — a container whose value type consists + * ENTIRELY of secret leaves, e.g. `db.vault` (a `Record`) — the walk drops that property/index signature from its + * PARENT entirely instead of keeping an empty, accept-anything + * `Objects` node: an all-secret container is itself secret-shaped, the + * same as a single secret leaf, so `db.vault` never appears anywhere in + * `ProjectConfigSchema` at all. This is distinct from an `Objects` node + * that was ALREADY empty at the SOURCE level before any stripping — + * `storage.analytics.buckets.*` and `storage.vector.buckets.*` are + * genuinely empty `Schema.Struct({})`s (`../storage.ts`), untouched by + * this walk, and still pass through as accept-anything leaves — the + * derived schema must not be stricter than `CliConfigSchema` itself, + * which behaves identically for those two, genuinely-empty structs. + * - Wraps every SURVIVING property in `optionalKey` (via + * {@link toOptionalAst}), recursing into its type — mirroring + * `DeepPartial`'s `{ readonly [K in keyof T]?: DeepPartial }` + * mapped type (`../sparse.ts`) at every object level reached, and + * recursing the same way into index-signature VALUE types (matching + * `DeepPartial`'s recursion into a `Record`'s value type — + * `Record` deep-partializes to `Record>`, not `X` verbatim). + * - Leaves an `Arrays` node completely untouched, INCLUDING its element + * types: `DeepPartial` special-cases arrays to pass `T` through + * verbatim rather than partializing element types (`../sparse.ts`), and + * no `x-secret` leaf sits inside an array anywhere in this schema + * (`../lib/secret-paths.ts`'s own docstring), so there is nothing this + * walk would otherwise need to change there anyway. + * - Strips every `checks` array attached DIRECTLY to an `Objects` node — + * the cross-field business-rule refinements this repo attaches with + * `.check()` on a whole struct (`requiredWhenEnabled` in + * `../auth/email.ts`/`../auth/providers.ts`, `validateSmsProviderSwitch` + * in `../auth/sms.ts`) encode invariants a deliberately sparse overlay + * cannot generally satisfy — e.g. `{ auth: { email: { smtp: { enabled: + * true } } } }` with no `host` yet is a legal, if incomplete, + * `ProjectConfig` fragment, but `requiredWhenEnabled("host", ...)` would + * reject it. Every LEAF-level check survives untouched, since it lives + * on a non-`Objects` node — today that's only `workers.*.instances`'s + * `Schema.Number.check(isInt(), isGreaterThanOrEqualTo(0))` and the + * `[workers]` record's own key pattern (`Schema.isPattern(...)` on + * `workerName`, `../workers.ts`). There is no port-range (or other + * numeric-bound) leaf check anywhere in this schema today. + * - Recurses into `Union` members (e.g. `storage.file_size_limit`'s + * `Schema.Union([String, Number])`, and every `Schema.Literals`-backed + * enum, which V4 also compiles to a `Union`), so a secret-bearing or + * object-shaped member nested inside one would still be reached. Every + * other node kind (every leaf: `String`, `Number`, `Boolean`, + * `Literal`, …) is returned unchanged — there is nothing further to + * drop or partialize on a leaf. This module's own AST node kinds are + * enumerated explicitly, via each class's PUBLIC constructor, rather + * than through a generic `.recur()`-style mechanism: unlike + * `.repos/effect`'s vendored source, the installed `effect` release's + * own `AST#recur` is `@internal` (absent from its published `.d.ts`), + * so a truly generic fallback isn't available through the public API + * surface this package is allowed to depend on. + * `./project-schema.unit.test.ts`'s AST-walk exhaustiveness guard walks + * the derived AST and fails loudly if a node kind outside this + * enumerated set (or a reintroduced `Suspend`, deliberately unhandled + * here — see {@link toDeepOptionalHostedAst}) ever appears, rather than + * silently mishandling it. + * + * `_apiResponse` (ADR 0019) is deliberately NOT part of this schema: it's + * attached as a non-enumerable property that ordinary decode/validation can + * never see, so there is nothing here for a schema to describe. + * + * Never `additionalProperties: false` ({@link toProjectConfigJsonSchema} + * passes `{ additionalProperties: true }` to `Schema.toJsonSchemaDocument`, + * and `ProjectConfigSchema` itself is never decoded with + * `onExcessProperty: "error"`): a `ProjectConfig` value can carry extra own + * keys a given schema VERSION doesn't yet model (a registry-mapped field a + * future release adds), and JSON Schema's own default is permissive — this + * derivation matches that norm rather than rejecting anything unrecognized. + */ +import type { StandardSchemaV1 } from "@standard-schema/spec"; +import { Schema, SchemaAST } from "effect"; +import { CliConfigSchema } from "../base.ts"; +import type { ProjectConfig } from "./project-config.ts"; + +function isSecretAst(ast: SchemaAST.AST): boolean { + return ast.annotations?.["x-secret"] === true; +} + +function hasObjectMembers(ast: SchemaAST.AST): boolean { + return ( + SchemaAST.isObjects(ast) && + (ast.propertySignatures.length > 0 || ast.indexSignatures.length > 0) + ); +} + +/** + * True when `original` was an `Objects` node with at least one member + * (property or index signature) before secret-stripping, and `transformed` — + * the same node's {@link toDeepOptionalHostedAst} result — ended up with + * none: every member was secret-shaped and got dropped, so the container + * itself is now secret-shaped too. Distinguishes that case from an `Objects` + * node that was ALREADY empty at the source level (`storage.analytics. + * buckets.*`/`storage.vector.buckets.*` — see this module's own doc + * comment), which must pass through unchanged rather than being treated as + * secret-shaped. + */ +function isAllSecretCollapsedContainer( + original: SchemaAST.AST, + transformed: SchemaAST.AST, +): boolean { + return hasObjectMembers(original) && !hasObjectMembers(transformed); +} + +/** + * Marks `ast` optional through the PUBLIC `Schema.optionalKey` combinator + * (`Schema.optionalKey(Schema.make(ast)).ast`) rather than the internal + * `SchemaAST.optionalKey` this repo's vendored `.repos/effect` snapshot + * exposes publicly but the installed `effect` release does not — see this + * module's own doc comment. Conscious exception to this repo's `as`-cast + * policy's spirit (a typed-constructor call standing in for one): `Schema.make` + * performs no structural check against the throwaway `unknown` `Codec` + * parameter here; only `ast` itself (read straight back off the wrapped + * schema) is used. + */ +function toOptionalAst(ast: SchemaAST.AST): SchemaAST.AST { + return Schema.optionalKey(Schema.make>(ast)).ast; +} + +/** + * `Suspend` is deliberately UNHANDLED here (falls through to the final + * `return ast` below, verbatim, untouched) rather than recursed into: no + * `Schema.suspend`-backed recursive type is reachable from the seven hosted + * sections today, so this is unreachable in practice, and + * `./project-schema.unit.test.ts`'s AST-walk exhaustiveness guard fails + * loudly the moment one is introduced — a reviewable prompt to design real + * `Suspend` handling (thunk identity/`$defs` implications included) instead + * of silently mishandling recursion. + */ +function toDeepOptionalHostedAst(ast: SchemaAST.AST): SchemaAST.AST { + if (SchemaAST.isObjects(ast)) { + const propertySignatures = ast.propertySignatures.flatMap((property) => { + if (isSecretAst(property.type)) { + return []; + } + const transformedType = toDeepOptionalHostedAst(property.type); + if (isAllSecretCollapsedContainer(property.type, transformedType)) { + return []; + } + return [new SchemaAST.PropertySignature(property.name, toOptionalAst(transformedType))]; + }); + const indexSignatures = ast.indexSignatures.flatMap((indexSignature) => { + if (isSecretAst(indexSignature.type)) { + return []; + } + const transformedType = toDeepOptionalHostedAst(indexSignature.type); + if (isAllSecretCollapsedContainer(indexSignature.type, transformedType)) { + return []; + } + return [new SchemaAST.IndexSignature(indexSignature.parameter, transformedType)]; + }); + return new SchemaAST.Objects( + propertySignatures, + indexSignatures, + ast.annotations, + undefined, + undefined, + ast.context, + undefined, + ); + } + if (SchemaAST.isArrays(ast)) { + return ast; + } + if (SchemaAST.isUnion(ast)) { + return new SchemaAST.Union( + ast.types.map(toDeepOptionalHostedAst), + ast.mode, + ast.annotations, + ast.checks, + ast.encoding, + ast.context, + ast.encodingChecks, + ); + } + return ast; +} + +// A literal field-picking object, not a `HOSTED_SECTION_KEYS.map(...)` +// reflection: `Schema.Struct`'s field type is inferred per-property from a +// literal object type, which a programmatic pick loses without an `as` cast +// (disallowed by this repo's typing policy) to restore. Each field schema +// below is still the exact one `CliConfigSchema` itself embeds (`../base.ts`), +// never a copy. +const hostedSectionsStruct = Schema.Struct({ + api: CliConfigSchema.fields.api, + auth: CliConfigSchema.fields.auth, + db: CliConfigSchema.fields.db, + realtime: CliConfigSchema.fields.realtime, + storage: CliConfigSchema.fields.storage, + workers: CliConfigSchema.fields.workers, + experimental: CliConfigSchema.fields.experimental, +}); + +// The literal pick above still names the same seven keys as +// `HOSTED_SECTION_KEYS` by hand, since a type-safe `Schema.Struct` field +// object can't be built from an array without an `as` cast. The two lists +// drifting apart (an edit to one without the other) is caught by +// `./project-schema.unit.test.ts`'s own assertion against +// `ProjectConfigSchema.ast`'s top-level property names vs. +// `HOSTED_SECTION_KEYS`, not by an import-time throw here (CLI-2234) — a +// schema-module import should never be able to crash a consumer's process +// for a condition a test already covers. + +const projectConfigAst = toDeepOptionalHostedAst(SchemaAST.toType(hostedSectionsStruct.ast)); + +/** + * The runtime shape {@link projectConfigAst} validates: {@link ProjectConfig} + * minus `_apiResponse`, which — being non-enumerable and never serialized — + * has no runtime representation for a schema to check. `Schema.make` performs + * no structural verification against this annotation (the same trust-the- + * caller contract as effect's own `Json: Codec = make(SchemaAST.Json)` + * precedent); the type-level pin in `./project-schema.unit.test.ts` cross- + * checks this exact type expression against `ProjectConfig` independently, so + * a future edit to either side that silently drifts fails to compile there. + */ +type ProjectConfigSchemaType = Omit; + +/** + * Runtime validation for {@link ProjectConfig} — both an Effect-native schema + * (decode/encode, `.ast`, …) and a spec-compliant Standard Schema + * (`~standard`), since {@link Schema.toStandardSchemaV1} augments and returns + * the SAME object rather than wrapping it in a second value. + * + * Annotated explicitly (rather than left inferred) because the inferred type + * names `StandardSchemaV1` from `@standard-schema/spec` — a package reachable + * only transitively through `effect` under pnpm's strict `node_modules` + * isolation — which tsc's declaration emit refuses to synthesize into + * `project-schema.d.ts` as non-portable. Explicitly importing the type here + * pins `@standard-schema/spec` as a direct dependency instead. Conscious + * exception to this repo's `as`-cast policy's spirit: `Schema.make`'s type + * parameter here is asserted, not verified, against `projectConfigAst` — see + * {@link ProjectConfigSchemaType}'s doc comment for the independent + * compile-time cross-check that catches drift instead. + */ +export const ProjectConfigSchema: StandardSchemaV1< + ProjectConfigSchemaType, + ProjectConfigSchemaType +> & + Schema.Codec = Schema.toStandardSchemaV1( + Schema.make>(projectConfigAst), +); + +/** JSON Schema (draft 2020-12) rendering of {@link ProjectConfigSchema}, mirroring `../base.ts`'s `toCliConfigJsonSchema`. */ +export function toProjectConfigJsonSchema() { + const document = Schema.toJsonSchemaDocument(ProjectConfigSchema, { + additionalProperties: true, + }); + return { + $schema: "https://json-schema.org/draft/2020-12/schema", + ...document.schema, + ...(Object.keys(document.definitions).length > 0 ? { $defs: document.definitions } : {}), + }; +} diff --git a/packages/config/src/project-config/project-schema.unit.test.ts b/packages/config/src/project-config/project-schema.unit.test.ts new file mode 100644 index 0000000000..53e2dced7c --- /dev/null +++ b/packages/config/src/project-config/project-schema.unit.test.ts @@ -0,0 +1,448 @@ +import { describe, expect, test } from "vitest"; +import { readFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { Schema, SchemaAST } from "effect"; +import * as SmolToml from "smol-toml"; +import { CliConfigSchema } from "../base.ts"; +import { isSecretPath, secretPathPatterns } from "../lib/secret-paths.ts"; +import { getDefaultCliConfig } from "../sparse.ts"; +import { HOSTED_SECTION_KEYS } from "./hosted-sections.ts"; +import { fromApiProjectConfig, fromConfigDocument, toProjectConfig } from "./project-config.ts"; +import type { ProjectConfig } from "./project-config.ts"; +import { ProjectConfigSchema, toProjectConfigJsonSchema } from "./project-schema.ts"; + +const decodeCliConfig = Schema.decodeUnknownSync(CliConfigSchema); +const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +const legacyFixturePath = join( + dirname(fileURLToPath(import.meta.url)), + "../../testdata/legacy-config.toml", +); + +function apiEnvelope(attributes: Record): unknown { + return { data: { type: "project_config", id: "abcdefghijklmnopqrst", attributes } }; +} + +describe("ProjectConfigSchema acceptance", () => { + test("an empty overlay validates", () => { + expect(decodeProjectConfig({})).toEqual({}); + }); + + test("a sparse, deeply nested overlay validates", () => { + expect(decodeProjectConfig({ auth: { site_url: "https://example.com" } })).toEqual({ + auth: { site_url: "https://example.com" }, + }); + }); + + test("a sparse overlay leaving required-looking siblings unset still validates", () => { + // `db.pooler`'s own fields (`pool_mode`, `default_pool_size`, …) are all + // present in `CliConfigSchema`, but this schema wraps every one of them + // `optionalKey` — a fragment naming only `enabled` must not fail just + // because it says nothing about the rest of the section. + expect(() => decodeProjectConfig({ db: { pooler: { enabled: true } } })).not.toThrow(); + }); + + test("fromConfigDocument's output over the default CliConfig validates", () => { + const projected = fromConfigDocument(getDefaultCliConfig()); + expect(() => decodeProjectConfig(projected)).not.toThrow(); + }); + + test("fromConfigDocument's output over the real legacy-config.toml fixture validates", () => { + const raw = SmolToml.parse(readFileSync(legacyFixturePath, "utf8")); + const config = decodeCliConfig(raw); + const projected = fromConfigDocument(config); + expect(() => decodeProjectConfig(projected)).not.toThrow(); + }); + + test("toProjectConfig's cliConfig arm validates", () => { + const projected = toProjectConfig({ cliConfig: { api: { max_rows: 100 } } }); + expect(() => decodeProjectConfig(projected)).not.toThrow(); + }); + + test("toProjectConfig's apiResponse arm validates, including the attached _apiResponse own property", () => { + const projected = toProjectConfig({ + apiResponse: apiEnvelope({ database: { major_version: 17 } }), + }); + expect(Object.getOwnPropertyNames(projected)).toContain("_apiResponse"); + expect(() => decodeProjectConfig(projected)).not.toThrow(); + }); + + test("an API-sourced value built directly through fromApiProjectConfig validates", () => { + const projected = fromApiProjectConfig(apiEnvelope({ database: { major_version: 17 } })); + expect(() => decodeProjectConfig(projected)).not.toThrow(); + }); + + // `db.vault` is dropped from the schema entirely (an all-secret + // `Record` container, project-schema.ts's + // `isAllSecretCollapsedContainer`) rather than kept as an empty, + // accept-anything node — so under this schema's permissive-excess design + // (never `additionalProperties: false`, never `onExcessProperty: "error"`), + // a `db.vault` of ANY shape is simply excess input: it validates, but is + // silently dropped from the decoded result rather than rejected. + test("db.vault of any shape validates but is dropped, since the schema no longer knows the key", () => { + expect(decodeProjectConfig({ db: { vault: 42 } })).toEqual({ db: {} }); + expect(decodeProjectConfig({ db: { vault: {} } })).toEqual({ db: {} }); + }); +}); + +describe("ProjectConfigSchema rejection", () => { + test("auth.site_url as a number is rejected", () => { + expect(() => decodeProjectConfig({ auth: { site_url: 123 } })).toThrow(); + }); + + test("db.pooler.pool_mode with an unrecognized literal is rejected", () => { + expect(() => + decodeProjectConfig({ db: { pooler: { pool_mode: "not-a-real-mode" } } }), + ).toThrow(); + }); + + test("db.pooler.pool_mode with a recognized literal is accepted", () => { + expect(() => + decodeProjectConfig({ db: { pooler: { pool_mode: "transaction" } } }), + ).not.toThrow(); + }); +}); + +describe("ProjectConfigSchema secret-strip exhaustiveness", () => { + // Schema-derived, exhaustive counterpart to a hand-picked field list + // (matching `project-config.unit.test.ts`'s own exhaustive-probe + // precedent): every `x-secret` path pattern the schema declares, rooted in + // one of the seven hosted sections, must be structurally absent from + // `ProjectConfigSchema`'s own AST — not merely absent from one hand-picked + // example. + const reachablePatterns = secretPathPatterns.filter((pattern) => + HOSTED_SECTION_KEYS.some((key) => key === (pattern[0] ?? "")), + ); + + test("guards the probe against a broken import silently emptying the pattern list", () => { + expect(reachablePatterns.length).toBeGreaterThan(0); + for (const pattern of reachablePatterns) { + const concretePath = pattern.map((segment) => (segment === "*" ? "probe_key" : segment)); + expect(isSecretPath(concretePath)).toBe(true); + } + }); + + /** + * Walks {@link ProjectConfigSchema}'s own AST along `pattern`, treating a + * `"*"` segment as "descend into the node's own index signature" and every + * other segment as "descend into the property signature of that name" — + * returns `undefined` the moment the path can no longer be followed, which + * is exactly the outcome a dropped secret property/index-signature + * produces. + */ + function findAtPattern( + ast: SchemaAST.AST, + pattern: ReadonlyArray, + ): SchemaAST.AST | undefined { + let current: SchemaAST.AST | undefined = ast; + for (const segment of pattern) { + if (current === undefined || !SchemaAST.isObjects(current)) { + return undefined; + } + current = + segment === "*" + ? current.indexSignatures[0]?.type + : current.propertySignatures.find((property) => property.name === segment)?.type; + } + return current; + } + + test("no x-secret path from the schema's own pattern list survives in ProjectConfigSchema's AST", () => { + for (const pattern of reachablePatterns) { + expect(findAtPattern(ProjectConfigSchema.ast, pattern)).toBeUndefined(); + } + }); + + // Guards against a vacuous pass: if an ANCESTOR of `pattern` vanished + // (e.g. a whole section got dropped by an unrelated bug), `findAtPattern` + // for the full secret path also returns `undefined` — indistinguishable, + // from that assertion alone, from the secret leaf being correctly + // stripped. Asserting the parent path is still reachable rules that out — + // EXCEPT for a known all-secret collapsed container (`db.vault`, a + // `Record` — project-schema.ts's + // `isAllSecretCollapsedContainer`), whose own immediate parent is dropped + // entirely rather than kept as an empty node. That one case is accepted + // explicitly (checking the GRANDPARENT is reachable instead, and that the + // container's own name no longer survives as a property there) rather + // than by walking arbitrarily far up the ancestor chain, which would mask + // an unrelated regression dropping some other, unexpected ancestor. + const KNOWN_ALL_SECRET_COLLAPSED_CONTAINER_PARENTS: ReadonlyArray> = [ + ["db", "vault"], + ]; + + test("the parent of every stripped x-secret path is still reachable, except a known all-secret collapsed container", () => { + for (const pattern of reachablePatterns) { + const parentPattern = pattern.slice(0, -1); + const parent = + parentPattern.length === 0 + ? ProjectConfigSchema.ast + : findAtPattern(ProjectConfigSchema.ast, parentPattern); + + if (parent !== undefined) { + continue; + } + + const isKnownAllSecretContainer = KNOWN_ALL_SECRET_COLLAPSED_CONTAINER_PARENTS.some( + (known) => + known.length === parentPattern.length && + known.every((segment, index) => segment === parentPattern[index]), + ); + expect( + isKnownAllSecretContainer, + `parent of ${JSON.stringify(pattern)} vanished unexpectedly (not a known all-secret collapsed container)`, + ).toBe(true); + + const grandparentPattern = parentPattern.slice(0, -1); + const grandparent = + grandparentPattern.length === 0 + ? ProjectConfigSchema.ast + : findAtPattern(ProjectConfigSchema.ast, grandparentPattern); + expect(grandparent, `grandparent of ${JSON.stringify(pattern)} vanished`).toBeDefined(); + + const droppedName = parentPattern[parentPattern.length - 1]; + if (grandparent !== undefined && SchemaAST.isObjects(grandparent)) { + expect( + grandparent.propertySignatures.some((property) => property.name === droppedName), + ).toBe(false); + } + } + }); + + /** + * Recursively collects the dotted path of every reachable `Objects` node + * with zero properties AND zero index signatures — the shape both a + * genuinely source-empty struct (`storage.analytics.buckets.*`, + * `storage.vector.buckets.*` — see `project-schema.ts`'s own doc comment) + * and (before CLI-2234's fix) an all-secret collapsed container would + * produce. `db.vault` is dropped entirely rather than emptied now, so it + * must NOT appear in this list — this is the "double-check no OTHER + * container becomes stripped-empty besides vault" guard. + */ + function collectEmptyObjectPaths( + ast: SchemaAST.AST, + path: ReadonlyArray, + seen: Set, + into: string[], + ): void { + if (SchemaAST.isUnion(ast)) { + if (seen.has(ast)) { + return; + } + seen.add(ast); + for (const member of ast.types) { + collectEmptyObjectPaths(member, path, seen, into); + } + return; + } + if (!SchemaAST.isObjects(ast) || seen.has(ast)) { + return; + } + seen.add(ast); + if (ast.propertySignatures.length === 0 && ast.indexSignatures.length === 0) { + into.push(path.join(".")); + return; + } + for (const property of ast.propertySignatures) { + collectEmptyObjectPaths(property.type, [...path, String(property.name)], seen, into); + } + for (const indexSignature of ast.indexSignatures) { + collectEmptyObjectPaths(indexSignature.type, [...path, "*"], seen, into); + } + } + + test("no all-secret container besides db.vault collapses to an empty, accept-anything node", () => { + const emptyObjectPaths: string[] = []; + collectEmptyObjectPaths(ProjectConfigSchema.ast, [], new Set(), emptyObjectPaths); + + expect(emptyObjectPaths.toSorted()).toEqual( + ["storage.analytics.buckets.*", "storage.vector.buckets.*"].toSorted(), + ); + }); +}); + +describe("ProjectConfigSchema hosted-section keys", () => { + // Moved from an import-time throw in `project-schema.ts` (CLI-2234): a + // schema-module import should never be able to crash a consumer's + // process for a condition a test already covers. Asserts against the + // PUBLIC, observable `ProjectConfigSchema.ast` rather than reaching into + // the module's private `hostedSectionsStruct`. + test("the schema's own top-level property names are exactly HOSTED_SECTION_KEYS", () => { + if (!SchemaAST.isObjects(ProjectConfigSchema.ast)) { + throw new Error("expected ProjectConfigSchema.ast to be an Objects node"); + } + const actualKeys = ProjectConfigSchema.ast.propertySignatures.map((property) => + String(property.name), + ); + expect(actualKeys.toSorted()).toEqual([...HOSTED_SECTION_KEYS].toSorted()); + }); +}); + +describe("ProjectConfigSchema derivation AST-walk exhaustiveness", () => { + // CLI-2234 group 7c/7d: `toDeepOptionalHostedAst` (`project-schema.ts`) + // enumerates AST node kinds explicitly rather than through a generic + // recursion helper (see that module's doc comment for why) and + // deliberately leaves `Suspend` unhandled. This walks the ACTUAL derived + // `ProjectConfigSchema.ast` and fails loudly the moment a node kind + // outside the set that derivation is written to understand appears, + // rather than letting a future schema addition silently fall through + // `toDeepOptionalHostedAst`'s final `return ast` (correct for a true + // leaf, silently wrong for an unhandled container/recursive kind). + const HANDLED_CONTAINER_TAGS = new Set(["Objects", "Arrays", "Union"]); + const HANDLED_LEAF_TAGS = new Set(["String", "Number", "Boolean", "Literal"]); + + function walk(ast: SchemaAST.AST, seen: Set): void { + if (seen.has(ast)) { + return; + } + seen.add(ast); + + if (HANDLED_CONTAINER_TAGS.has(ast._tag) || HANDLED_LEAF_TAGS.has(ast._tag)) { + if (SchemaAST.isObjects(ast)) { + for (const property of ast.propertySignatures) { + walk(property.type, seen); + } + for (const indexSignature of ast.indexSignatures) { + walk(indexSignature.type, seen); + } + } else if (SchemaAST.isArrays(ast)) { + for (const element of ast.elements) { + walk(element, seen); + } + for (const rest of ast.rest) { + walk(rest, seen); + } + } else if (SchemaAST.isUnion(ast)) { + for (const member of ast.types) { + walk(member, seen); + } + } + return; + } + + throw new Error( + `ProjectConfigSchema's derived AST contains a node kind ("${ast._tag}") that ` + + "toDeepOptionalHostedAst (project-schema.ts) isn't written to understand yet — " + + "the derivation must learn this new node kind (secret-stripping, optionality, and " + + "checks-stripping all need a deliberate decision for it) before this guard can pass.", + ); + } + + test("every node kind reachable from ProjectConfigSchema.ast is in the handled set", () => { + walk(ProjectConfigSchema.ast, new Set()); + }); +}); + +describe("ProjectConfigSchema local-only sections", () => { + test("a full CliConfig's local-only sections are silently ignored, not validated or echoed back", () => { + const result = decodeProjectConfig(getDefaultCliConfig()); + for (const localOnlyKey of [ + "project_id", + "studio", + "edge_runtime", + "analytics", + "functions", + "local_smtp", + "remotes", + ]) { + expect(Object.hasOwn(result, localOnlyKey)).toBe(false); + } + }); +}); + +describe("ProjectConfigSchema Standard Schema interop", () => { + test("~standard reports the effect vendor", () => { + expect(ProjectConfigSchema["~standard"].vendor).toBe("effect"); + expect(ProjectConfigSchema["~standard"].version).toBe(1); + }); + + test("~standard.validate returns a value on success", async () => { + const outcome = ProjectConfigSchema["~standard"].validate({ + auth: { site_url: "https://example.com" }, + }); + const result = outcome instanceof Promise ? await outcome : outcome; + expect(result.issues).toBeUndefined(); + if (!result.issues) { + expect(result.value).toEqual({ auth: { site_url: "https://example.com" } }); + } + }); + + test("~standard.validate returns issues with paths on failure", async () => { + const outcome = ProjectConfigSchema["~standard"].validate({ auth: { site_url: 123 } }); + const result = outcome instanceof Promise ? await outcome : outcome; + expect(result.issues).toBeDefined(); + expect(result.issues?.[0]?.path).toBeDefined(); + }); +}); + +describe("toProjectConfigJsonSchema", () => { + const typedDocument = toProjectConfigJsonSchema(); + // `JsonSchema.JsonSchema` (`effect`) is an open `[x: string]: unknown` + // record with no named properties, so TypeScript can't statically type + // `typedDocument`'s nested `properties`/`required`/… fields — the same + // reason `io.unit.test.ts`'s own `toCliConfigJsonSchema` coverage asserts + // through a stringified rendering rather than typed property access. A + // JSON round trip gives every assertion below a plainly-navigable value + // without an `as` cast. + const document = JSON.parse(JSON.stringify(typedDocument)); + + test("declares the draft 2020-12 dialect", () => { + expect(typedDocument.$schema).toBe("https://json-schema.org/draft/2020-12/schema"); + }); + + test("top-level properties are exactly the seven hosted sections", () => { + expect(Object.keys(document.properties).sort()).toEqual([...HOSTED_SECTION_KEYS].toSorted()); + }); + + test("no required array forces presence anywhere spot-checked", () => { + expect(document.required).toBeUndefined(); + expect(document.properties.auth.required).toBeUndefined(); + expect(document.properties.db.properties.pooler.required).toBeUndefined(); + }); + + test("db.vault disappears from the schema entirely (an all-secret container is dropped, not emptied)", () => { + expect(Object.hasOwn(document.properties.db.properties, "vault")).toBe(false); + }); + + test("is JSON-serializable and stable across two calls", () => { + expect(() => JSON.stringify(typedDocument)).not.toThrow(); + expect(JSON.parse(JSON.stringify(toProjectConfigJsonSchema()))).toEqual(document); + }); +}); + +describe("ProjectConfigSchema type-level pin", () => { + // Compile-time drift guard (CLI-2234 design requirement, mirroring + // `apps/cli/src/shared/config/project-config-api-drift.unit.test.ts`'s + // `_typeDriftGuard`/`AssertNever` style): `ProjectConfigSchema`'s own + // generic annotation (`project-schema.ts`) and `ProjectConfig` + // (`project-config.ts`) are independent expressions of the same shape — + // this file re-derives the expected shape from `ProjectConfig` itself + // (rather than importing `project-schema.ts`'s private type alias) so a + // future edit to either side that silently drifts fails to compile here. + // + // Both directions hold because the only structural difference between the + // two sides is optional-property PRESENCE: `ProjectConfigSchema`'s Type + // never carries an `_apiResponse` key at all (never modeled, ADR 0019), and + // `ProjectConfig` types every `x-secret` leaf as present-but-optional even + // though the runtime derivation drops those keys entirely from the schema. + // TypeScript's structural assignability does not require a source type to + // have (or lack) an optional property the target also lacks (or has), so a + // missing or extra OPTIONAL property never blocks assignability in either + // direction — verified by actually compiling both functions below, not + // merely asserted in prose. + type ExpectedProjectConfigSchemaType = Omit; + type DerivedProjectConfigSchemaType = typeof ProjectConfigSchema.Type; + + const _derivedAssignableToExpected: ( + value: DerivedProjectConfigSchemaType, + ) => ExpectedProjectConfigSchemaType = (value) => value; + + const _expectedAssignableToDerived: ( + value: ExpectedProjectConfigSchemaType, + ) => DerivedProjectConfigSchemaType = (value) => value; + + test("both assignability directions compile", () => { + expect(typeof _derivedAssignableToExpected).toBe("function"); + expect(typeof _expectedAssignableToDerived).toBe("function"); + }); +}); diff --git a/packages/config/src/project.ts b/packages/config/src/project.ts index 489375182a..e06cd2302f 100644 --- a/packages/config/src/project.ts +++ b/packages/config/src/project.ts @@ -1,7 +1,10 @@ -import { Effect, FileSystem, Redacted } from "effect"; +import { Effect, FileSystem } from "effect"; import { CliProjectEnvParseError } from "./errors.ts"; -import { ENV_CAPTURE_REGEX, ENV_CAPTURE_REGEX_STRICT, isEnvReference } from "./lib/env.ts"; -import { isSecretPath } from "./lib/secret-paths.ts"; +import { + resolveCliConfigValueAtPath, + toPathSegments, + type ResolvedCliConfigValue, +} from "./lib/resolve.ts"; import { findCliProjectPaths, type CliProjectPaths } from "./paths.ts"; const dotEnvLinePattern = @@ -14,22 +17,6 @@ export interface CliProjectEnvironment { readonly sources: Readonly>; } -type ResolvedString = string | Redacted.Redacted; - -export type ResolvedCliConfigValue = T extends string - ? ResolvedString - : T extends ReadonlyArray - ? ReadonlyArray> - : T extends Array - ? Array> - : T extends Record - ? { readonly [K in keyof T]: ResolvedCliConfigValue } & { - readonly [key: string]: ResolvedCliConfigValue; - } - : T extends object - ? { readonly [K in keyof T]: ResolvedCliConfigValue } - : T; - function normalizeAmbientEnv( baseEnv: Readonly> | undefined, ): Record { @@ -205,7 +192,11 @@ export interface LoadCliProjectEnvironmentOptions { readonly skipEnvLocal?: boolean; } -export interface ResolveCliConfigOptions { +/** + * Not covered by semver — exported from `@supabase/config/internal` only. See + * that module's header for why. + */ +export interface InternalResolveCliConfigOptions { /** * Opt into Go/viper-parity `env()` matching (case-agnostic * `^env\((.*)\)$`). Defaults to `false`, which uses the pre-PR-#5765 strict @@ -253,102 +244,15 @@ export const loadCliProjectEnvironment = Effect.fnUntraced(function* ( } satisfies CliProjectEnvironment; }); -function interpolateLeafValue( - value: string, - env: Readonly>, - goViperCompat: boolean, -): string { - const match = (goViperCompat ? ENV_CAPTURE_REGEX : ENV_CAPTURE_REGEX_STRICT).exec(value); - const envName = match?.[1]; - - if (envName === undefined) { - return value; - } - - const resolved = env[envName]; - // Preserve the literal `env(VAR)` verbatim when VAR is unset OR present but - // empty (e.g. a dotenv `KEY=` line). Matches Go's `LoadEnvHook` - // (`apps/cli-go/pkg/config/decode_hooks.go:19-24`: `len(env) > 0`), which - // only substitutes a non-empty value — same gate as `substituteEnvLeaf` in - // `lib/env.ts`. Without this, a present-but-empty `env(...)` secret (e.g. - // `edge_runtime.secrets.FOO = "env(EMPTY)"`) resolves to `""` here, gets - // redacted by `redactValue` as a real value instead of skipped as an - // unresolved literal, and `secrets set` uploads a blank secret Go would - // never send. - if (resolved === undefined || resolved === "") { - return value; - } - - return resolved; -} - -function toPathSegments(path: string): ReadonlyArray { - if (path === "") { - return []; - } - - return path.split(".").filter((segment) => segment.length > 0); -} - -function interpolateValue( - value: unknown, - env: Readonly>, - goViperCompat: boolean, -): unknown { - if (Array.isArray(value)) { - return value.map((item) => interpolateValue(item, env, goViperCompat)); - } - - if (typeof value === "object" && value !== null) { - const result: Record = {}; - - for (const [key, child] of Object.entries(value)) { - result[key] = interpolateValue(child, env, goViperCompat); - } - - return result; - } - - if (typeof value === "string") { - return interpolateLeafValue(value, env, goViperCompat); - } - - return value; -} - -function redactValue(value: unknown, path: ReadonlyArray, goViperCompat: boolean): unknown { - if (Array.isArray(value)) { - return value.map((item, index) => redactValue(item, [...path, String(index)], goViperCompat)); - } - - if (typeof value === "object" && value !== null) { - const result: Record = {}; - - for (const [key, child] of Object.entries(value)) { - result[key] = redactValue(child, [...path, key], goViperCompat); - } - - return result; - } - - if (typeof value === "string" && isSecretPath(path) && !isEnvReference(value, goViperCompat)) { - return Redacted.make(value, { label: path.join(".") }); - } - - return value; -} - -function resolveCliConfigValueAtPath( - value: unknown, - cliProjectEnv: Pick, - path: ReadonlyArray, - goViperCompat: boolean, -): unknown { - const interpolated = interpolateValue(value, cliProjectEnv.values, goViperCompat); - return redactValue(interpolated, path, goViperCompat); -} - /** + * Effect-typed counterpart of `./lib/resolve.ts`'s plain sync + * `resolveCliConfigValue`, additionally accepting the internal-only + * `goViperCompat` option (see {@link InternalResolveCliConfigOptions}). + * `../effect.ts` re-exports this explicitly, which wins over the sync + * version's star re-export through `./index.ts` (see that module's doc + * comment on the deliberate shadowing) — `@supabase/config/internal` + * re-exports this same function typed to show `goViperCompat`. + * * `cliProjectEnv` only needs `.values` (`Pick`) — * a caller that already has a project's env values but not the full * `CliProjectEnvironment` shape (e.g. `paths`/`loadedPaths`/`sources`) can pass @@ -358,16 +262,15 @@ export function resolveCliConfigValue( value: T, cliProjectEnv: Pick, configPath: string, - options?: ResolveCliConfigOptions, + options?: InternalResolveCliConfigOptions, ): Effect.Effect> { - return Effect.sync( - () => - resolveCliConfigValueAtPath( - value, - cliProjectEnv, - toPathSegments(configPath), - options?.goViperCompat ?? false, - ) as ResolvedCliConfigValue, + return Effect.sync(() => + resolveCliConfigValueAtPath( + value, + cliProjectEnv, + toPathSegments(configPath), + options?.goViperCompat ?? false, + ), ); } @@ -376,15 +279,14 @@ export function resolveCliConfigSubtree( value: T, cliProjectEnv: Pick, pathPrefix: string, - options?: ResolveCliConfigOptions, + options?: InternalResolveCliConfigOptions, ): Effect.Effect> { - return Effect.sync( - () => - resolveCliConfigValueAtPath( - value, - cliProjectEnv, - toPathSegments(pathPrefix), - options?.goViperCompat ?? false, - ) as ResolvedCliConfigValue, + return Effect.sync(() => + resolveCliConfigValueAtPath( + value, + cliProjectEnv, + toPathSegments(pathPrefix), + options?.goViperCompat ?? false, + ), ); } diff --git a/packages/config/src/project.unit.test.ts b/packages/config/src/project.unit.test.ts index b42c92b25e..e8f16508b7 100644 --- a/packages/config/src/project.unit.test.ts +++ b/packages/config/src/project.unit.test.ts @@ -5,7 +5,10 @@ import { mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Effect, FileSystem, Path, Redacted } from "effect"; -import { findCliProjectRootFor, loadCliProjectEnvironmentFor } from "./bun.ts"; +import { + findCliProjectRoot as findCliProjectRootFromBun, + loadCliProjectEnvironment as loadCliProjectEnvironmentFromBun, +} from "./bun.ts"; import { CliConfigParseError, CliProjectEnvParseError } from "./errors.ts"; import { findCliProjectPaths, @@ -14,6 +17,7 @@ import { resolveCliConfigSubtree, resolveCliConfigValue, } from "./effect.ts"; +import { resolveCliConfigValue as resolveCliConfigValueInternal } from "./internal.ts"; function makeTempProject(): string { return mkdtempSync(join(tmpdir(), "supabase-project-config-")); @@ -44,7 +48,7 @@ describe("project discovery and lazy env resolution", () => { expect(paths?.projectRoot).toBe(packageRoot); expect(paths?.supabaseDir).toBe(join(packageRoot, "supabase")); expect(paths?.configPath).toBe(join(packageRoot, "supabase", "config.toml")); - expect(await findCliProjectRootFor(nestedCwd)).toBe(packageRoot); + expect(await findCliProjectRootFromBun(nestedCwd)).toBe(packageRoot); } finally { await rm(cwd, { recursive: true, force: true }); } @@ -169,7 +173,7 @@ describe("project discovery and lazy env resolution", () => { join(packageRoot, "supabase", ".env.local"), ]); - const fromBun = await loadCliProjectEnvironmentFor({ + const fromBun = await loadCliProjectEnvironmentFromBun({ cwd: nestedCwd, baseEnv: { OVERRIDE_ME: "from-ambient", @@ -608,9 +612,12 @@ jwt_secret = "env(lowercase_secret)" const projectEnv = await runConfigEffect(loadCliProjectEnvironment({ cwd: projectRoot })); const resolved = await runConfigEffect( - resolveCliConfigValue(loaded!.config.auth.jwt_secret, projectEnv!, "auth.jwt_secret", { - goViperCompat: true, - }), + resolveCliConfigValueInternal( + loaded!.config.auth.jwt_secret, + projectEnv!, + "auth.jwt_secret", + { goViperCompat: true }, + ), ); expect(Redacted.isRedacted(resolved)).toBe(true); diff --git a/packages/config/src/promise-facade.stdin.unit.test.ts b/packages/config/src/promise-facade.stdin.unit.test.ts index 294083c7f9..d7ffa70ecb 100644 --- a/packages/config/src/promise-facade.stdin.unit.test.ts +++ b/packages/config/src/promise-facade.stdin.unit.test.ts @@ -5,7 +5,7 @@ import { rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { Context, Effect, Layer, Option, Terminal } from "effect"; -import { findCliProjectRootFor } from "./bun.ts"; +import { findCliProjectRoot } from "./bun.ts"; // CLI-2231 regression guard: `BunServices.layer` (the full Bun platform // services bundle) pulls in `Terminal`, which attaches a permanent @@ -32,7 +32,7 @@ describe("promise-facade stdin-leak regression (CLI-2231)", () => { const before = process.stdin.listenerCount("end"); try { - await findCliProjectRootFor(cwd); + await findCliProjectRoot(cwd); expect(process.stdin.listenerCount("end")).toBe(before); } finally { diff --git a/packages/config/src/promise-facade.ts b/packages/config/src/promise-facade.ts index b056b73458..5c7bf888dc 100644 --- a/packages/config/src/promise-facade.ts +++ b/packages/config/src/promise-facade.ts @@ -14,19 +14,37 @@ import { findCliProjectPaths, findCliProjectRoot } from "./paths.ts"; import { cliConfigStoreLayer } from "./cli-config.layer.ts"; import { CliConfigStore } from "./cli-config.service.ts"; +/** + * Names deliberately mirror `@supabase/config/effect` one-to-one — the + * subpath itself (`/io` vs `/effect`) conveys Promise-vs-Effect, not the + * member names. + * + * A rejection from `loadCliConfig`, `loadCliConfigFile`, or `saveCliConfig` + * can carry any of five typed failures — this package's own + * `CliConfigParseError`, `DuplicateRemoteProjectIdError`, + * `InvalidRemoteProjectIdError`, `CliProjectEnvParseError`, or `PlatformError` + * (from `effect/PlatformError`) for a host/OS failure — distinguish via + * `instanceof`. One exception: `saveCliConfig`'s atomic-write step maps a + * rename failure to a defect rather than one of these typed failures (see + * `io.ts`'s `writeFileAtomic`) — the returned promise still rejects, but with + * the raw, un-mapped failure, not an instance of any class listed above. This + * is a deliberate design choice (a rename failure after a successful write + * indicates something is wrong with the filesystem itself, not a recoverable + * config condition), not an oversight. + */ export interface CliConfigIo { readonly loadCliConfig: ( cwd: string, options?: LoadCliConfigOptions, ) => Promise; - readonly findCliProjectRootFor: (cwd: string) => Promise; - readonly findCliProjectPathsFor: (cwd: string) => Promise; + readonly findCliProjectRoot: (cwd: string) => Promise; + readonly findCliProjectPaths: (cwd: string) => Promise; readonly loadCliConfigFile: (path: string) => Promise; - readonly loadCliProjectEnvironmentFor: ( + readonly loadCliProjectEnvironment: ( options: LoadCliProjectEnvironmentOptions, ) => Promise; readonly saveCliConfig: (options: SaveCliConfigOptions) => Promise; - readonly loadFunctionsManifest: (cwd: string) => Promise; + readonly inferFunctionsManifest: (cwd: string) => Promise; } /** @@ -61,16 +79,16 @@ export function makeCliConfigIo( return { loadCliConfig: async (cwd, options) => getRuntime().runPromise(CliConfigStore.use((store) => store.load(cwd, options))), - findCliProjectRootFor: async (cwd) => getRuntime().runPromise(findCliProjectRoot(cwd)), - findCliProjectPathsFor: async (cwd) => getRuntime().runPromise(findCliProjectPaths(cwd)), + findCliProjectRoot: async (cwd) => getRuntime().runPromise(findCliProjectRoot(cwd)), + findCliProjectPaths: async (cwd) => getRuntime().runPromise(findCliProjectPaths(cwd)), loadCliConfigFile: async (path) => getRuntime().runPromise(CliConfigStore.use((store) => store.loadFile(path))), - loadCliProjectEnvironmentFor: async (options) => + loadCliProjectEnvironment: async (options) => getRuntime().runPromise( loadCliProjectEnvironment({ ...options, baseEnv: options.baseEnv ?? process.env }), ), saveCliConfig: async (options) => getRuntime().runPromise(CliConfigStore.use((store) => store.save(options))), - loadFunctionsManifest: async (cwd) => getRuntime().runPromise(inferFunctionsManifest({ cwd })), + inferFunctionsManifest: async (cwd) => getRuntime().runPromise(inferFunctionsManifest({ cwd })), }; } diff --git a/packages/config/src/promise-facade.unit.test.ts b/packages/config/src/promise-facade.unit.test.ts index cc42b71018..920b1c7f28 100644 --- a/packages/config/src/promise-facade.unit.test.ts +++ b/packages/config/src/promise-facade.unit.test.ts @@ -14,12 +14,12 @@ import * as nodeFacade from "./node.ts"; import { makeCliConfigIo } from "./promise-facade.ts"; const { - findCliProjectPathsFor, - findCliProjectRootFor, - loadFunctionsManifest, + findCliProjectPaths, + findCliProjectRoot, + inferFunctionsManifest, loadCliConfig, loadCliConfigFile, - loadCliProjectEnvironmentFor, + loadCliProjectEnvironment, saveCliConfig, } = bunFacade; @@ -86,7 +86,7 @@ describe("promise-facade via the Bun entrypoint", () => { } }); - test("findCliProjectRootFor and findCliProjectPathsFor resolve from a nested cwd inside a temp project", async () => { + test("findCliProjectRoot and findCliProjectPaths resolve from a nested cwd inside a temp project", async () => { const cwd = makeTempProject(); const nested = join(cwd, "apps", "web", "src", "components"); @@ -95,8 +95,8 @@ describe("promise-facade via the Bun entrypoint", () => { await mkdir(nested, { recursive: true }); await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "nested-ref"\n'); - const root = await findCliProjectRootFor(nested); - const paths = await findCliProjectPathsFor(nested); + const root = await findCliProjectRoot(nested); + const paths = await findCliProjectPaths(nested); expect(root).toBe(cwd); expect(paths).toEqual({ @@ -111,18 +111,18 @@ describe("promise-facade via the Bun entrypoint", () => { } }); - test("findCliProjectRootFor and findCliProjectPathsFor resolve to null when there is no project", async () => { + test("findCliProjectRoot and findCliProjectPaths resolve to null when there is no project", async () => { const cwd = makeTempProject(); try { - await expect(findCliProjectRootFor(cwd)).resolves.toBeNull(); - await expect(findCliProjectPathsFor(cwd)).resolves.toBeNull(); + await expect(findCliProjectRoot(cwd)).resolves.toBeNull(); + await expect(findCliProjectPaths(cwd)).resolves.toBeNull(); } finally { await rm(cwd, { recursive: true, force: true }); } }); - test("loadCliProjectEnvironmentFor reads supabase/.env layered under an explicit baseEnv", async () => { + test("loadCliProjectEnvironment reads supabase/.env layered under an explicit baseEnv", async () => { const cwd = makeTempProject(); try { @@ -133,7 +133,7 @@ describe("promise-facade via the Bun entrypoint", () => { // `baseEnv` is passed explicitly (never the default `process.env`) so // this assertion can't be satisfied by an unrelated variable leaking in // from the real process environment. - const projectEnv = await loadCliProjectEnvironmentFor({ cwd, baseEnv: {} }); + const projectEnv = await loadCliProjectEnvironment({ cwd, baseEnv: {} }); expect(projectEnv?.values.GREETING).toBe("hello-from-dotenv"); expect(projectEnv?.sources.GREETING).toBe(".env"); @@ -143,7 +143,7 @@ describe("promise-facade via the Bun entrypoint", () => { } }); - test("loadCliProjectEnvironmentFor honors an explicit baseEnv instead of silently defaulting to process.env", async () => { + test("loadCliProjectEnvironment honors an explicit baseEnv instead of silently defaulting to process.env", async () => { const cwd = makeTempProject(); try { @@ -151,7 +151,7 @@ describe("promise-facade via the Bun entrypoint", () => { await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "env-ref"\n'); await writeFile(join(cwd, "supabase", ".env"), "GREETING=from-dotenv\n"); - const projectEnv = await loadCliProjectEnvironmentFor({ + const projectEnv = await loadCliProjectEnvironment({ cwd, baseEnv: { GREETING: "from-explicit-base-env" }, }); @@ -163,14 +163,14 @@ describe("promise-facade via the Bun entrypoint", () => { } }); - test("loadFunctionsManifest resolves an empty manifest when no functions directory exists", async () => { + test("inferFunctionsManifest resolves an empty manifest when no functions directory exists", async () => { const cwd = makeTempProject(); try { await mkdir(join(cwd, "supabase"), { recursive: true }); await writeFile(join(cwd, "supabase", "config.toml"), 'project_id = "functions-ref"\n'); - await expect(loadFunctionsManifest(cwd)).resolves.toEqual({}); + await expect(inferFunctionsManifest(cwd)).resolves.toEqual({}); } finally { await rm(cwd, { recursive: true, force: true }); } @@ -203,12 +203,12 @@ describe("promise-facade via the Bun entrypoint", () => { }); const expectedFacadeFunctionNames = [ - "findCliProjectPathsFor", - "findCliProjectRootFor", - "loadFunctionsManifest", + "findCliProjectPaths", + "findCliProjectRoot", + "inferFunctionsManifest", "loadCliConfig", "loadCliConfigFile", - "loadCliProjectEnvironmentFor", + "loadCliProjectEnvironment", "saveCliConfig", ]; @@ -295,8 +295,8 @@ describe("promise-facade singleton runtime", () => { ); const io = makeCliConfigIo(countingLayer); - await io.findCliProjectRootFor(cwd); - await io.findCliProjectRootFor(cwd); + await io.findCliProjectRoot(cwd); + await io.findCliProjectRoot(cwd); expect(builds).toBe(1); } finally { diff --git a/packages/config/src/schema-metadata.ts b/packages/config/src/schema-metadata.ts index 19be5bab62..01dca5072c 100644 --- a/packages/config/src/schema-metadata.ts +++ b/packages/config/src/schema-metadata.ts @@ -1 +1,3 @@ export const CLI_CONFIG_SCHEMA_URL = "https://supabase.com/docs/cli/config.schema.json"; +/** Sibling of {@link CLI_CONFIG_SCHEMA_URL} for `ProjectConfigSchema`'s generated JSON Schema document (`toProjectConfigJsonSchema`) — same `/docs/cli/` path, same `apps/cli/scripts/generate-docs.ts` pathname-derivation convention. */ +export const PROJECT_CONFIG_SCHEMA_URL = "https://supabase.com/docs/cli/project-config.schema.json"; diff --git a/packages/config/tsconfig.build.json b/packages/config/tsconfig.build.json new file mode 100644 index 0000000000..73b1cfd0ae --- /dev/null +++ b/packages/config/tsconfig.build.json @@ -0,0 +1,31 @@ +{ + // Compiles the published `dist/` output for `pnpm build`. Extends the same + // `@tsconfig/bun` strictness baseline as `tsconfig.json` (dev/tests, run + // under Bun) but swaps its bundler-mode settings for a real Node-compatible + // ESM emit, so the two configs share strictness without fighting over + // `module`/`moduleResolution`/`types`/`noEmit`, which this file overrides. + // + // `"types": []` drops the ambient `bun` global types from `tsconfig.json` + // and doubles as the @types/bun leak audit CLI-2234 requires: if any + // compiled module reaches for a Bun-only global (`Bun.*`, `Bun.env`, ...), + // this compile fails on a missing name instead of silently type-checking + // against `bun-types`. + "extends": "@tsconfig/bun/tsconfig.json", + "compilerOptions": { + "lib": ["ESNext"], + "types": [], + "module": "nodenext", + "moduleResolution": "nodenext", + "allowImportingTsExtensions": true, + "rewriteRelativeImportExtensions": true, + "noEmit": false, + "declaration": true, + // `dist/` ships alongside `src/` in the published tarball (see `files` in + // package.json), so declaration maps resolve back to real source. + "declarationMap": true, + "outDir": "dist", + "rootDir": "src" + }, + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/packages/config/tsconfig.declarations.json b/packages/config/tsconfig.declarations.json new file mode 100644 index 0000000000..be7328b4d5 --- /dev/null +++ b/packages/config/tsconfig.declarations.json @@ -0,0 +1,14 @@ +{ + // Declaration-only companion to `tsconfig.build.json`, used by + // `tools/config-api-compare.ts` (repo root) to emit both the base and head + // `.d.ts` trees it diffs (CLI-2234). A second minimal config — rather than + // overriding `declarationMap` on the CLI — because `declarationMap` is a + // boolean compiler option with no dedicated CLI negation flag; `--outDir` + // is a plain string override and stays safe to pass on the command line at + // each call site. + "extends": "./tsconfig.build.json", + "compilerOptions": { + "emitDeclarationOnly": true, + "declarationMap": false + } +} diff --git a/packages/config/vitest.config.ts b/packages/config/vitest.config.ts index 7a35ffa8d7..6c2cb8409a 100644 --- a/packages/config/vitest.config.ts +++ b/packages/config/vitest.config.ts @@ -1,6 +1,21 @@ +import { defaultClientConditions, defaultServerConditions } from "vite"; import { defineConfig } from "vitest/config"; +// This package publishes a `bun` export condition pointing at its +// TypeScript source (see package.json's `exports` map); without it, Vite's +// resolver falls through to the `default` condition and loads the built +// `dist/*.js` output instead — stale, or missing entirely on a fresh clone +// before the package has been built. Extending (not replacing) Vite's +// default condition lists keeps every other package's exports resolution +// unchanged. Required on every inline `test.projects` entry too: Vitest +// builds a separate Vite config per project and does not inherit these from +// the root config (see PR #6366 finding 0). +const workspacePackageResolve = { conditions: [...defaultClientConditions, "bun"] }; +const workspacePackageSsrResolve = { conditions: [...defaultServerConditions, "bun"] }; + export default defineConfig({ + resolve: workspacePackageResolve, + ssr: { resolve: workspacePackageSsrResolve }, test: { passWithNoTests: true, coverage: { @@ -13,6 +28,8 @@ export default defineConfig({ }, projects: [ { + resolve: workspacePackageResolve, + ssr: { resolve: workspacePackageSsrResolve }, test: { name: "unit", include: ["**/*.unit.test.ts"], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 646b0b4476..5d76c74cd0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -240,6 +240,9 @@ importers: typescript: specifier: 'catalog:' version: 7.0.2 + vite: + specifier: ^6.0.0 || ^7.0.0 || ^8.0.0 + version: 8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) @@ -400,6 +403,9 @@ importers: packages/config: dependencies: + '@standard-schema/spec': + specifier: ^1.1.0 + version: 1.1.0 dedent: specifier: ^1.7.2 version: 1.7.2 @@ -428,6 +434,9 @@ importers: typescript: specifier: 'catalog:' version: 7.0.2 + vite: + specifier: ^6.0.0 || ^7.0.0 || ^8.0.0 + version: 8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0) vitest: specifier: 'catalog:' version: 4.1.10(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.10)(vite@8.1.4(@types/node@26.2.0)(esbuild@0.28.2)(jiti@2.7.0)(yaml@2.9.0)) diff --git a/tools/config-api-compare.ts b/tools/config-api-compare.ts new file mode 100644 index 0000000000..caa320481a --- /dev/null +++ b/tools/config-api-compare.ts @@ -0,0 +1,591 @@ +/** + * Diffs `@supabase/config`'s compiled `.d.ts` surface between a PR's base and + * head commits — a per-PR type-surface signal with zero committed artifacts + * (CLI-2234; replaces the checked-in `packages/config/api-report/` mirror). + * + * Usage: + * bun tools/config-api-compare.ts [--base ] + * + * Base ref resolution, in order: `--base`, then `GITHUB_BASE_REF` (prefixed + * `origin/`), then `origin/develop`. Resolves `git merge-base HEAD `, + * fetching `origin/` at depth 1 first when the ref is missing locally + * (a shallow CI clone only has the PR's own commits). If HEAD's own checkout + * is also shallow, a depth-1 base fetch still can't produce a common + * ancestor — the tool then unshallows (or deepens) the checkout and retries + * once more before giving up and skipping the compare. + * + * Emits declarations twice with the same compiler settings — head from + * `packages/config/src` directly, base from a `git archive` of the + * merge-base extracted into `packages/config/.api-compare/base/` (so + * dependency resolution walks up to `packages/config/node_modules` using the + * CURRENT install, no second `pnpm install` needed) — then diffs the two + * `.d.ts` trees. + * + * Advisory at PR time (a base-vs-head diff has no acceptance artifact to + * gate on); the hard release-time gate is tracked under CLI-2233. + * + * Exit codes: 0 identical (or compare skipped), 1 surface differs, 2 tool + * failure. + */ + +import { appendFile, cp, mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import process from "node:process"; +import { parseArgs } from "node:util"; + +const repoRoot = path.resolve(import.meta.dir, ".."); +const packageRoot = path.join(repoRoot, "packages", "config"); +const tscBinPath = path.join(packageRoot, "node_modules", ".bin", "tsc"); + +interface GitResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +} + +async function runGit(args: readonly string[], cwd: string): Promise { + const proc = Bun.spawn(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" }); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + return { exitCode, stdout, stderr }; +} + +function requireBinaries(names: readonly string[]): void { + const missing = names.filter((name) => Bun.which(name) === null); + if (missing.length > 0) { + throw new Error(`this tool requires ${missing.join(", ")} on PATH.`); + } +} + +/** `--base` wins; else `GITHUB_BASE_REF` (a PR's base branch name, no remote prefix) under `origin/`; else `origin/develop`. */ +function resolveBaseRef(cliBase: string | undefined): string { + if (cliBase) { + return cliBase; + } + const githubBaseRef = process.env.GITHUB_BASE_REF; + if (githubBaseRef) { + return `origin/${githubBaseRef}`; + } + return "origin/develop"; +} + +type MergeBaseResolution = + | { readonly kind: "resolved"; readonly sha: string } + | { readonly kind: "skip"; readonly reason: string }; + +/** + * Resolves `git merge-base HEAD `. A shallow CI checkout only has + * the PR's own commits, so `` can be locally unresolvable — when + * it's an `origin/` ref, fetch that branch at depth 1 and retry + * before giving up. + * + * A depth-1 base fetch only helps when the base ref itself was simply never + * fetched; it cannot produce a common ancestor when HEAD's own checkout is + * shallow too (the `check` job's default `actions/checkout` depth), since + * neither side's shallow history reaches the other's. In that case, unshallow + * (or deepen, if `--unshallow` errors because the checkout is already + * complete) the repository, refetch the base ref in full, and retry once + * more. If a merge-base still can't be resolved, this is an advisory check — + * skip the compare instead of failing the tool. + * + * A non-`origin/` ref (e.g. an explicit `--base `) that doesn't resolve + * locally is a caller error, not something this tool can fetch its way out + * of. + */ +async function resolveMergeBase(baseRef: string): Promise { + const attempt = await runGit(["merge-base", "HEAD", baseRef], repoRoot); + if (attempt.exitCode === 0) { + return { kind: "resolved", sha: attempt.stdout.trim() }; + } + + if (!baseRef.startsWith("origin/")) { + throw new Error( + `could not resolve base ref "${baseRef}" (git merge-base: ${attempt.stderr.trim()}). Pass a ` + + `ref that already exists locally, or one under "origin/" so it can be fetched.`, + ); + } + + const branchName = baseRef.slice("origin/".length); + console.warn( + `[config-api-compare] ${baseRef} did not resolve locally (${attempt.stderr.trim()}); fetching ` + + `origin/${branchName} at depth 1...`, + ); + const fetch = await runGit( + ["fetch", "--depth=1", "origin", `+${branchName}:refs/remotes/origin/${branchName}`], + repoRoot, + ); + if (fetch.exitCode !== 0) { + throw new Error(`git fetch --depth=1 origin ${branchName} failed: ${fetch.stderr.trim()}`); + } + + const retry = await runGit(["merge-base", "HEAD", baseRef], repoRoot); + if (retry.exitCode === 0) { + return { kind: "resolved", sha: retry.stdout.trim() }; + } + + const isShallow = await runGit(["rev-parse", "--is-shallow-repository"], repoRoot); + if (isShallow.stdout.trim() !== "true") { + throw new Error( + `could not resolve base ref "${baseRef}" even after fetching origin/${branchName}: ` + + retry.stderr.trim(), + ); + } + + console.warn( + `[config-api-compare] HEAD's own checkout is shallow, so a depth-1 ${baseRef} fetch can't ` + + "produce a common ancestor; unshallowing before retrying merge-base...", + ); + const unshallow = await runGit(["fetch", "--unshallow", "origin", branchName], repoRoot); + if (unshallow.exitCode !== 0) { + console.warn( + `[config-api-compare] git fetch --unshallow failed (${unshallow.stderr.trim()}); falling ` + + "back to git fetch --deepen=100000...", + ); + const deepen = await runGit(["fetch", "--deepen=100000", "origin"], repoRoot); + if (deepen.exitCode !== 0) { + return { + kind: "skip", + reason: + `could not unshallow (${unshallow.stderr.trim()}) or deepen ` + + `(${deepen.stderr.trim()}) the checkout to resolve a merge-base against ${baseRef}.`, + }; + } + } + + const fullFetch = await runGit( + ["fetch", "origin", `+${branchName}:refs/remotes/origin/${branchName}`], + repoRoot, + ); + if (fullFetch.exitCode !== 0) { + return { + kind: "skip", + reason: `could not fully fetch origin/${branchName} after unshallowing: ${fullFetch.stderr.trim()}.`, + }; + } + + const finalRetry = await runGit(["merge-base", "HEAD", baseRef], repoRoot); + if (finalRetry.exitCode === 0) { + return { kind: "resolved", sha: finalRetry.stdout.trim() }; + } + + return { + kind: "skip", + reason: + `could not resolve a merge-base between HEAD and ${baseRef} even after unshallowing ` + + `(git merge-base: ${finalRetry.stderr.trim()}).`, + }; +} + +async function shortSha(rev: string): Promise { + const result = await runGit(["rev-parse", "--short", rev], repoRoot); + return result.exitCode === 0 ? result.stdout.trim() : rev; +} + +async function pathExistsAtRev(rev: string, relativePath: string): Promise { + const proc = Bun.spawn(["git", "cat-file", "-e", `${rev}:${relativePath}`], { + cwd: repoRoot, + stdout: "ignore", + stderr: "ignore", + }); + return (await proc.exited) === 0; +} + +/** + * `git archive ` piped straight into `tar -x`, stripping + * the shared `packages/config/` prefix (2 path components) so the extracted + * tree lands directly under `destDir`. + */ +async function archiveAndExtract( + rev: string, + relativePaths: readonly string[], + destDir: string, +): Promise { + const archiveProc = Bun.spawn(["git", "archive", rev, ...relativePaths], { + cwd: repoRoot, + stdout: "pipe", + stderr: "pipe", + }); + const tarProc = Bun.spawn(["tar", "-x", "-C", destDir, "--strip-components=2"], { + stdin: archiveProc.stdout, + stdout: "pipe", + stderr: "pipe", + }); + const [archiveExitCode, tarExitCode, archiveStderr, tarStderr] = await Promise.all([ + archiveProc.exited, + tarProc.exited, + new Response(archiveProc.stderr).text(), + new Response(tarProc.stderr).text(), + ]); + if (archiveExitCode !== 0) { + throw new Error( + `git archive ${rev} ${relativePaths.join(" ")} failed: ${archiveStderr.trim()}`, + ); + } + if (tarExitCode !== 0) { + throw new Error(`tar extraction into ${destDir} failed: ${tarStderr.trim()}`); + } +} + +/** + * Materializes the base revision's `packages/config/src` (plus its + * declaration-emit config) under `baseExtractDir`, INSIDE `packages/config`, + * so tsc's node_modules walk from there reaches `packages/config/node_modules` + * with the CURRENT install — no second `pnpm install` needed. + * + * `tsconfig.build.json` is always the HEAD copy (tooling, not part of the + * compared surface, and required so `tsconfig.declarations.json`'s own + * `"extends": "./tsconfig.build.json"` resolves inside the extracted tree). + * `tsconfig.declarations.json` is the base revision's own copy when it has + * one; a merge-base that predates this file (e.g. still on the checked-in + * `api-report/` mirror, or older) falls back to the HEAD copy for the emit + * settings. + */ +async function extractBaseTree(mergeBase: string, baseExtractDir: string): Promise { + await mkdir(baseExtractDir, { recursive: true }); + + const srcRelativePath = "packages/config/src"; + const declarationsRelativePath = "packages/config/tsconfig.declarations.json"; + + if (!(await pathExistsAtRev(mergeBase, srcRelativePath))) { + throw new Error(`base revision ${mergeBase} has no ${srcRelativePath} — cannot compare`); + } + + const hasDeclarationsConfig = await pathExistsAtRev(mergeBase, declarationsRelativePath); + const archivePaths = hasDeclarationsConfig + ? [srcRelativePath, declarationsRelativePath] + : [srcRelativePath]; + await archiveAndExtract(mergeBase, archivePaths, baseExtractDir); + + await cp( + path.join(packageRoot, "tsconfig.build.json"), + path.join(baseExtractDir, "tsconfig.build.json"), + ); + if (!hasDeclarationsConfig) { + console.warn( + `[config-api-compare] base revision ${await shortSha(mergeBase)} predates ` + + "tsconfig.declarations.json — using the HEAD copy for emit settings.", + ); + await cp( + path.join(packageRoot, "tsconfig.declarations.json"), + path.join(baseExtractDir, "tsconfig.declarations.json"), + ); + } +} + +interface EmitResult { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; + readonly fileCount: number; +} + +async function countDeclarationFiles(dir: string): Promise { + const glob = new Bun.Glob("**/*.d.ts"); + let count = 0; + for await (const _relativePath of glob.scan({ cwd: dir })) { + count++; + } + return count; +} + +async function listDeclarationFiles(dir: string): Promise { + const glob = new Bun.Glob("**/*.d.ts"); + const relativePaths: string[] = []; + for await (const relativePath of glob.scan({ cwd: dir })) { + relativePaths.push(relativePath); + } + return relativePaths.sort(); +} + +/** + * Spawns this package's own `node_modules/.bin/tsc` directly rather than + * `pnpm exec tsc` (the same corepack-avoidance lesson as the old + * `api-report.unit.test.ts`: a bun-shimmed `PATH` can route `pnpm`'s launcher + * through Bun's `node:sqlite`-less Node-compat layer). `noEmitOnError` + * defaults to false, so declarations are emitted even when the base tree's + * old source doesn't type-check cleanly against the current install's + * (newer) dependencies — a genuinely empty output is the only signal treated + * as a hard failure by the caller. + */ +async function emitDeclarations( + projectPath: string, + outDir: string, + cwd: string, +): Promise { + const proc = Bun.spawn([tscBinPath, "-p", projectPath, "--outDir", outDir], { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const [exitCode, stdout, stderr] = await Promise.all([ + proc.exited, + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + ]); + const fileCount = await countDeclarationFiles(outDir); + return { exitCode, stdout, stderr, fileCount }; +} + +async function unifiedDiff( + oldPath: string, + newPath: string, + oldLabel: string, + newLabel: string, +): Promise { + const proc = Bun.spawn(["diff", "-u", "-L", oldLabel, "-L", newLabel, oldPath, newPath], { + stdout: "pipe", + stderr: "pipe", + }); + const [, stdout] = await Promise.all([proc.exited, new Response(proc.stdout).text()]); + return stdout; +} + +interface FileEntry { + readonly status: "added" | "removed" | "changed"; + readonly path: string; + readonly diff: string; +} + +interface CompareResult { + readonly identical: boolean; + readonly entries: readonly FileEntry[]; +} + +/** Diffs the two emitted `.d.ts` trees (`**\/*.d.ts` only — the glob itself never matches `.d.ts.map`). */ +async function diffDeclarationTrees(headDir: string, baseDir: string): Promise { + const [headFiles, baseFiles] = await Promise.all([ + listDeclarationFiles(headDir), + listDeclarationFiles(baseDir), + ]); + const headSet = new Set(headFiles); + const baseSet = new Set(baseFiles); + + const entries: FileEntry[] = []; + + for (const relativePath of headFiles) { + if (!baseSet.has(relativePath)) { + entries.push({ + status: "added", + path: relativePath, + diff: await unifiedDiff( + "/dev/null", + path.join(headDir, relativePath), + "/dev/null", + `head/${relativePath}`, + ), + }); + continue; + } + + const [headContent, baseContent] = await Promise.all([ + readFile(path.join(headDir, relativePath), "utf8"), + readFile(path.join(baseDir, relativePath), "utf8"), + ]); + if (headContent !== baseContent) { + entries.push({ + status: "changed", + path: relativePath, + diff: await unifiedDiff( + path.join(baseDir, relativePath), + path.join(headDir, relativePath), + `base/${relativePath}`, + `head/${relativePath}`, + ), + }); + } + } + + for (const relativePath of baseFiles) { + if (!headSet.has(relativePath)) { + entries.push({ + status: "removed", + path: relativePath, + diff: await unifiedDiff( + path.join(baseDir, relativePath), + "/dev/null", + `base/${relativePath}`, + "/dev/null", + ), + }); + } + } + + entries.sort((a, b) => a.path.localeCompare(b.path)); + return { identical: entries.length === 0, entries }; +} + +function countByStatus(entries: readonly FileEntry[], status: FileEntry["status"]): number { + return entries.filter((entry) => entry.status === status).length; +} + +function renderTextReport(baseLabel: string, headLabel: string, result: CompareResult): string { + const lines: string[] = [`Config type-surface diff: ${baseLabel} -> ${headLabel}`, ""]; + if (result.identical) { + lines.push("No type-surface differences."); + return lines.join("\n"); + } + + lines.push( + `Added: ${countByStatus(result.entries, "added")}, ` + + `Removed: ${countByStatus(result.entries, "removed")}, ` + + `Changed: ${countByStatus(result.entries, "changed")}`, + "", + ); + for (const entry of result.entries) { + lines.push(`--- ${entry.status} ${entry.path} ---`, entry.diff.trimEnd(), ""); + } + return lines.join("\n"); +} + +function renderMarkdownSummary( + baseLabel: string, + headLabel: string, + result: CompareResult, +): string { + const lines: string[] = [ + "## Config type-surface diff (advisory)", + "", + `Comparing \`${baseLabel}\` against \`${headLabel}\` for \`@supabase/config\`'s compiled ` + + "declaration surface. Advisory only — see CLI-2233 for the planned release-time hard gate.", + "", + ]; + if (result.identical) { + lines.push("No type-surface differences."); + return lines.join("\n"); + } + + lines.push( + `**${countByStatus(result.entries, "added")} added, ` + + `${countByStatus(result.entries, "removed")} removed, ` + + `${countByStatus(result.entries, "changed")} changed**`, + "", + ); + for (const entry of result.entries) { + lines.push( + `
${entry.status}: ${entry.path}`, + "", + "```diff", + entry.diff.trimEnd(), + "```", + "", + "
", + "", + ); + } + return lines.join("\n"); +} + +function renderShallowHistorySkippedSummary( + baseRef: string, + headLabel: string, + reason: string, +): string { + return [ + "## Config type-surface diff (advisory)", + "", + `⚠️ Compare skipped (shallow history): could not resolve a merge-base between \`${headLabel}\` ` + + `and \`${baseRef}\`: ${reason}`, + ].join("\n"); +} + +function renderSkippedSummary(baseLabel: string, headLabel: string, baseEmit: EmitResult): string { + return [ + "## Config type-surface diff (advisory)", + "", + `⚠️ Compare skipped: the base revision (\`${baseLabel}\`) declaration emit produced zero ` + + `\`.d.ts\` files against \`${headLabel}\` (tsc exit ${baseEmit.exitCode}). Old source failing ` + + 'to emit against the current install\'s dependencies is treated as "nothing to compare" ' + + "rather than a false positive.", + ].join("\n"); +} + +async function writeStepSummary(markdown: string): Promise { + const summaryPath = process.env.GITHUB_STEP_SUMMARY; + if (!summaryPath) { + return; + } + await appendFile(summaryPath, `${markdown}\n`); +} + +async function main(): Promise { + requireBinaries(["git", "tar", "diff"]); + + const { values } = parseArgs({ options: { base: { type: "string" } } }); + const baseRef = resolveBaseRef(values.base); + const mergeBaseResolution = await resolveMergeBase(baseRef); + if (mergeBaseResolution.kind === "skip") { + const headLabel = await shortSha("HEAD"); + console.warn(`[config-api-compare] WARNING: ${mergeBaseResolution.reason}`); + await writeStepSummary( + renderShallowHistorySkippedSummary(baseRef, headLabel, mergeBaseResolution.reason), + ); + return 0; + } + const mergeBase = mergeBaseResolution.sha; + const [baseLabel, headLabel] = await Promise.all([shortSha(mergeBase), shortSha("HEAD")]); + console.log( + `[config-api-compare] comparing merge-base ${baseLabel} (of ${baseRef}) against HEAD ${headLabel}...`, + ); + + const compareDir = path.join(packageRoot, ".api-compare"); + const baseExtractDir = path.join(compareDir, "base"); + const headOutDir = await mkdtemp(path.join(tmpdir(), "supabase-config-api-compare-head-")); + const baseOutDir = await mkdtemp(path.join(tmpdir(), "supabase-config-api-compare-base-")); + + try { + await rm(compareDir, { recursive: true, force: true }); + + console.log("[config-api-compare] emitting head declarations..."); + const headEmit = await emitDeclarations( + path.join(packageRoot, "tsconfig.declarations.json"), + headOutDir, + packageRoot, + ); + if (headEmit.fileCount === 0) { + throw new Error( + `head declaration emit produced zero .d.ts files (tsc exit ${headEmit.exitCode}):\n` + + `${headEmit.stdout}\n${headEmit.stderr}`, + ); + } + + console.log("[config-api-compare] extracting and emitting base declarations..."); + await extractBaseTree(mergeBase, baseExtractDir); + const baseEmit = await emitDeclarations( + path.join(baseExtractDir, "tsconfig.declarations.json"), + baseOutDir, + baseExtractDir, + ); + if (baseEmit.fileCount === 0) { + console.warn( + `[config-api-compare] WARNING: base declaration emit produced zero .d.ts files (tsc exit ` + + `${baseEmit.exitCode}) — skipping the compare rather than reporting a false surface diff.\n` + + baseEmit.stderr, + ); + await writeStepSummary(renderSkippedSummary(baseLabel, headLabel, baseEmit)); + return 0; + } + + const result = await diffDeclarationTrees(headOutDir, baseOutDir); + console.log(renderTextReport(baseLabel, headLabel, result)); + await writeStepSummary(renderMarkdownSummary(baseLabel, headLabel, result)); + + return result.identical ? 0 : 1; + } finally { + await Promise.all([ + rm(compareDir, { recursive: true, force: true }), + rm(headOutDir, { recursive: true, force: true }), + rm(baseOutDir, { recursive: true, force: true }), + ]); + } +} + +try { + process.exit(await main()); +} catch (error) { + console.error(`[config-api-compare] ${error instanceof Error ? error.message : String(error)}`); + process.exit(2); +} diff --git a/turbo.json b/turbo.json index a258a26efc..ba4a14ebe6 100644 --- a/turbo.json +++ b/turbo.json @@ -63,7 +63,7 @@ "@supabase/config#build": { "cache": true, "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/.bun-version", "$TURBO_ROOT$/mise.lock"], - "outputs": ["dist/schema.json"] + "outputs": ["dist/**"] }, "@supabase/api#generate": { "cache": false, @@ -72,15 +72,20 @@ }, "@supabase/docs#generate": { "cache": true, - "dependsOn": ["supabase#build"], + "dependsOn": ["supabase#build", "@supabase/config#build"], "inputs": [ "$TURBO_DEFAULT$", "!content/docs/commands/**", "!public/cli/config.schema.json", + "!public/cli/project-config.schema.json", "$TURBO_ROOT$/.bun-version", "$TURBO_ROOT$/mise.lock" ], - "outputs": ["content/docs/commands/**", "public/cli/config.schema.json"] + "outputs": [ + "content/docs/commands/**", + "public/cli/config.schema.json", + "public/cli/project-config.schema.json" + ] }, "@supabase/docs#build": { "cache": true, @@ -89,6 +94,7 @@ "$TURBO_DEFAULT$", "!content/docs/commands/**", "!public/cli/config.schema.json", + "!public/cli/project-config.schema.json", "$TURBO_ROOT$/.bun-version", "$TURBO_ROOT$/mise.lock" ],