diff --git a/apps/cli/README.md b/apps/cli/README.md index 4c2a3b8367..fc555da61b 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -127,6 +127,13 @@ can surface `Downloading` before normal runtime states. CLI-managed stacks use l direct listeners and Realtime start with the stack, while HTTP services activate on first proxied use. The package API itself keeps eager startup as its default. +Project files do not flow directly into `@supabase/stack`. The CLI's local-stack launch Adapter +loads one resolved project-config/environment snapshot, inspects the raw document where explicit +presence matters, and translates CLI exclusions, project paths, readiness policy, and pinned +runtime versions into the package-owned `StackConfig`. Its compile-checked parity ledger records +every project field as mapped, not applicable, or explicitly unsupported so later parity work +cannot silently add another command-local mapping. + Useful companion docs: - [`../../packages/stack/docs/architecture.md`](../../packages/stack/docs/architecture.md) diff --git a/apps/cli/src/legacy/commands/config/push/config-sync/storage.sync.ts b/apps/cli/src/legacy/commands/config/push/config-sync/storage.sync.ts index 66705a0e7b..0ed4d67cfd 100644 --- a/apps/cli/src/legacy/commands/config/push/config-sync/storage.sync.ts +++ b/apps/cli/src/legacy/commands/config/push/config-sync/storage.sync.ts @@ -1,8 +1,8 @@ -import type { ProjectConfig } from "@supabase/config"; +import { parseStorageSizeBytes, type ProjectConfig } from "@supabase/config"; import { diff } from "./config-sync.diff.ts"; import { encodeToml, type TomlField, type TomlValue } from "./config-sync.toml.ts"; -import { bytesSize, intToUint, ramInBytes } from "../../../../shared/legacy-size-units.ts"; +import { bytesSize, intToUint } from "../../../../shared/legacy-size-units.ts"; /** * Push-subset of Go's `storage` struct (`pkg/config/storage.go`). `toml:"-"` @@ -144,7 +144,7 @@ export function storageSubsetFromConfig( name, { public: b.public, - file_size_limit: ramInBytes(b.file_size_limit), + file_size_limit: parseStorageSizeBytes(b.file_size_limit), allowed_mime_types: b.allowed_mime_types, objects_path: b.objects_path, } satisfies BucketSubset, @@ -152,7 +152,7 @@ export function storageSubsetFromConfig( ); return { enabled: s.enabled, - file_size_limit: ramInBytes(s.file_size_limit), + file_size_limit: parseStorageSizeBytes(s.file_size_limit), image_transformation: presence.imageTransformation ? { enabled: s.image_transformation?.enabled ?? false } : undefined, diff --git a/apps/cli/src/legacy/commands/start/lib/db-setup.ts b/apps/cli/src/legacy/commands/start/lib/db-setup.ts index c9722c8cdd..07ea835359 100644 --- a/apps/cli/src/legacy/commands/start/lib/db-setup.ts +++ b/apps/cli/src/legacy/commands/start/lib/db-setup.ts @@ -89,7 +89,7 @@ import { legacyExecSqlFile, } from "../../../shared/legacy-migration-apply.ts"; import type { LegacyMigrationSeedError } from "../../../shared/legacy-seed.ts"; -import { ramInBytes } from "../../../shared/legacy-size-units.ts"; +import { parseStorageSizeBytes } from "@supabase/config"; import { LegacyMigrationVaultError, legacyUpsertVaultSecrets, @@ -344,7 +344,7 @@ function legacyStartStorageMigrateEnv(input: { input.dbHost, input.dbPassword, ), - FILE_SIZE_LIMIT: String(ramInBytes(input.fileSizeLimit)), + FILE_SIZE_LIMIT: String(parseStorageSizeBytes(input.fileSizeLimit)), STORAGE_BACKEND: "file", STORAGE_FILE_BACKEND_PATH: "/mnt", TENANT_ID: "stub", @@ -417,7 +417,7 @@ const legacyStartInitSchema15 = Effect.fnUntraced(function* ( } if (input.config.storage.enabled) { // `legacyStartStorageMigrateEnv` parses `storage.file_size_limit` via - // `ramInBytes`, which throws on a malformed value — a plain synchronous + // the canonical Storage size parser, which throws on a malformed value — a plain synchronous // throw here would become an uncaught Effect defect (`Effect.tapError`'s // rollback trigger below only fires on typed `Fail` causes, never `Die` // ones), leaking Postgres's already-created container/network/volume. diff --git a/apps/cli/src/legacy/commands/start/services/gotrue.service.ts b/apps/cli/src/legacy/commands/start/services/gotrue.service.ts index b915a3d2b8..2885bde803 100644 --- a/apps/cli/src/legacy/commands/start/services/gotrue.service.ts +++ b/apps/cli/src/legacy/commands/start/services/gotrue.service.ts @@ -57,7 +57,7 @@ import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts import { legacyFormatGoDuration, legacyParseGoDuration, -} from "../../../shared/legacy-go-duration.ts"; +} from "../../../../shared/config/go-duration.ts"; import { LEGACY_DEFAULT_SIGNING_KEY } from "../../../shared/legacy-go-jwt.ts"; import type { LegacyResolvedAuthEmail } from "../../../shared/legacy-local-config-values.ts"; import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; diff --git a/apps/cli/src/legacy/commands/start/services/storage.service.ts b/apps/cli/src/legacy/commands/start/services/storage.service.ts index 439a465b9e..415594f5d2 100644 --- a/apps/cli/src/legacy/commands/start/services/storage.service.ts +++ b/apps/cli/src/legacy/commands/start/services/storage.service.ts @@ -40,10 +40,9 @@ * before passing `s3ProtocolEnabled`/`vectorBucketsEnabled` in. */ -import type { ProjectConfig } from "@supabase/config"; +import { parseStorageSizeBytes, type ProjectConfig } from "@supabase/config"; import { legacyServiceContainerName } from "../../../shared/legacy-docker-ids.ts"; -import { ramInBytes } from "../../../shared/legacy-size-units.ts"; import type { LegacyStartContainerSpec } from "../lib/docker-create-args.ts"; import { legacyEnvOrDefault } from "../lib/legacy-env-or-default.ts"; import { @@ -118,7 +117,7 @@ export interface LegacyStorageEnvInput { readonly dbHost: string; /** See `legacyStartInternalDbPassword` (`../lib/internal-db-connection.ts`). */ readonly dbPassword: string; - /** `config.storage.file_size_limit`, e.g. `"50MiB"` — converted to a byte count via `ramInBytes`. */ + /** `config.storage.file_size_limit`, e.g. `"50MiB"` — converted to a byte count. */ readonly fileSizeLimit: ProjectConfig["storage"]["file_size_limit"]; /** `LegacyLocalConfigValues.storageS3Region`. */ readonly s3Region: string; @@ -155,7 +154,7 @@ export function legacyBuildStorageEnv(input: LegacyStorageEnvInput): Record ramInBytes(storageFileSizeLimit)); + yield* wrapConfigOverride("storage.file_size_limit", () => + parseStorageSizeBytes(storageFileSizeLimit), + ); // Same gap for `storage.vector.enabled` — both the long-running Storage // container AND `legacySeedBucketsRun`'s `effectiveLocalStorageConfig` // splice further down must see the same already-overridden value (Go's diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts index 74582272eb..5b2118be81 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.ts @@ -1,3 +1,4 @@ +import { parseStorageSizeBytes } from "@supabase/config"; import { Effect, type FileSystem, Option, type Path } from "effect"; import * as SmolToml from "smol-toml"; import { @@ -24,7 +25,6 @@ import { import { LegacyDbConfigLoadError } from "./legacy-db-config.errors.ts"; import { parseDotEnv } from "./legacy-dotenv.ts"; import { legacyStrToArr } from "./legacy-local-config-values.ts"; -import { ramInBytes } from "./legacy-size-units.ts"; import { legacyCollectDotenvPrivateKeys, legacyDecryptSecret, @@ -1335,7 +1335,7 @@ const readDbTomlCore = Effect.fnUntraced(function* ( const limitString = typeof rawLimit === "number" ? String(rawLimit) : legacyExpandEnv(rawLimit, lookup); try { - ramInBytes(limitString); + parseStorageSizeBytes(limitString); } catch { return yield* Effect.fail( new LegacyDbConfigLoadError({ diff --git a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts index 09b88bdc40..f1614ed69b 100644 --- a/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts +++ b/apps/cli/src/legacy/shared/legacy-db-config.toml-read.unit.test.ts @@ -941,7 +941,7 @@ describe("legacyReadDbToml", () => { it.effect("accepts a bare-number [storage.buckets.].file_size_limit", () => { // `@supabase/config`'s schema allows file_size_limit as either a quoted // human-readable string or a bare byte count; the numeric form must normalize to - // a string before `ramInBytes` parses it rather than being rejected outright. + // a string before the canonical size parser sees it rather than being rejected outright. const dir = withConfig("[storage.buckets.avatars]\nfile_size_limit = 5242880\n"); return read(dir).pipe( Effect.tap(() => Effect.sync(() => rmSync(dir, { recursive: true, force: true }))), diff --git a/apps/cli/src/legacy/shared/legacy-size-units.ts b/apps/cli/src/legacy/shared/legacy-size-units.ts index 68cf70e2ab..8c591546a8 100644 --- a/apps/cli/src/legacy/shared/legacy-size-units.ts +++ b/apps/cli/src/legacy/shared/legacy-size-units.ts @@ -1,9 +1,8 @@ /** - * Ports of `github.com/docker/go-units` used by Go's `sizeInBytes` - * (`pkg/config/config.go`). `file_size_limit` config values are parsed with - * `RAMInBytes` and re-serialised in the diff with `BytesSize` (`sizeInBytes` - * implements `MarshalText`, so BurntSushi emits a quoted human-readable size, - * e.g. `"5MiB"`). + * Remaining formatting helpers from `github.com/docker/go-units` used by Go's + * `sizeInBytes` (`pkg/config/config.go`). Parsing is owned by + * `@supabase/config`; this module only re-serialises byte counts with + * `BytesSize` and preserves Go's signed-to-unsigned conversion behavior. * * Shared across the legacy shell: `config push` (storage/auth/api/db diffing) * and `seed buckets` (which converts each `[storage.buckets.*].file_size_limit` @@ -12,95 +11,8 @@ * @see github.com/docker/go-units@v0.5.0/size.go */ -const BINARY_MAP: Readonly> = { - k: 1024, - m: 1024 ** 2, - g: 1024 ** 3, - t: 1024 ** 4, - p: 1024 ** 5, -}; - const BINARY_ABBRS = ["B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB"] as const; -const DIGIT_OR_DOT_OR_SPACE = "0123456789. "; - -/** - * Port of `units.RAMInBytes` — parses a human-readable RAM size (1024-based, - * case-insensitive, optional trailing `b`) into bytes. Throws on an unparseable - * string (Go returns an error that aborts config load). - */ -export function ramInBytes(sizeStr: string): number { - let sep = -1; - for (let i = 0; i < sizeStr.length; i++) { - if (DIGIT_OR_DOT_OR_SPACE.includes(sizeStr[i] as string)) sep = i; - } - if (sep === -1) { - throw new Error(`invalid size: '${sizeStr}'`); - } - let num: string; - let sfx: string; - if (sizeStr[sep] !== " ") { - num = sizeStr.slice(0, sep + 1); - sfx = sizeStr.slice(sep + 1); - } else { - num = sizeStr.slice(0, sep); - sfx = sizeStr.slice(sep + 1); - } - // Go's `RAMInBytes` (docker/go-units v0.5.0) hands the WHOLE numeric part to - // `strconv.ParseFloat`, which rejects a string that isn't a complete float. - // JS `Number.parseFloat` instead silently parses a valid prefix (`1.2.3` → 1.2, - // `1 2` → 1), so validate the numeric part against Go's float grammar first: - // optional sign, a leading OR trailing dot, optional exponent, and single - // underscores BETWEEN digits (Go 1.13+ literal rule — no leading/trailing/ - // doubled `_`, none adjacent to `.`/sign). The digit group `\d(?:_?\d)*` - // enforces the underscore placement. This accepts Go-valid forms (`.5`, `1.`, - // `1e6`, `+5`, `1_000`) and rejects the prefix hazards (`1.2.3`, `1 2`, - // leading-space, `0x10`, `_1`, `1_`). A negative value is rejected post-parse - // below (matching Go's `size < 0` check); `1e309`→Infinity by the isFinite check. - if ( - !/^[+-]?(?:\d(?:_?\d)*(?:\.(?:\d(?:_?\d)*)?)?|\.\d(?:_?\d)*)([eE][+-]?\d(?:_?\d)*)?$/.test(num) - ) { - throw new Error(`invalid size: '${sizeStr}'`); - } - // Strip the (already-validated, between-digits) underscores before parsing: - // JS `Number.parseFloat("1_000")` stops at the underscore (→1), unlike Go. - const size = Number.parseFloat(num.replace(/_/g, "")); - // Reject NaN and ±Infinity: Go's `strconv.ParseFloat` returns a range error - // for an overflowing numeral like `1e309` (which JS parses to Infinity), so it - // must fail config load rather than flow through as `null` in the request body. - if (!Number.isFinite(size)) { - throw new Error(`invalid size: '${sizeStr}'`); - } - if (size < 0) { - throw new Error(`invalid size: '${sizeStr}'`); - } - if (sfx.length === 0) { - return Math.trunc(size); - } - if (sfx.length > 3) { - throw new Error(`invalid suffix: '${sfx}'`); - } - sfx = sfx.toLowerCase(); - if (sfx[0] === "b") { - if (sfx.length > 1) { - throw new Error(`invalid suffix: '${sfx}'`); - } - return Math.trunc(size); - } - const mul = BINARY_MAP[sfx[0] as string]; - if (mul === undefined) { - throw new Error(`invalid suffix: '${sfx}'`); - } - // The suffix may have a trailing "b" or "ib" (e.g. KiB or MB). - if (sfx.length === 2 && sfx[1] !== "b") { - throw new Error(`invalid suffix: '${sfx}'`); - } - if (sfx.length === 3 && sfx.slice(1) !== "ib") { - throw new Error(`invalid suffix: '${sfx}'`); - } - return Math.trunc(size * mul); -} - /** * Port of Go's `fmt`-style `%.4g`: at most 4 significant digits, trailing zeros * removed, no exponent for the magnitudes `BytesSize` produces (scaled to diff --git a/apps/cli/src/legacy/shared/legacy-storage-bucket-config.ts b/apps/cli/src/legacy/shared/legacy-storage-bucket-config.ts index 1f76e73cc1..382e3bfd9f 100644 --- a/apps/cli/src/legacy/shared/legacy-storage-bucket-config.ts +++ b/apps/cli/src/legacy/shared/legacy-storage-bucket-config.ts @@ -1,4 +1,4 @@ -import { ramInBytes } from "./legacy-size-units.ts"; +import { parseStorageSizeBytes } from "@supabase/config"; import type { LegacyUpsertBucketProps } from "./legacy-storage-gateway.ts"; /** @@ -20,7 +20,7 @@ import type { LegacyUpsertBucketProps } from "./legacy-storage-gateway.ts"; * maps to a config-load error. */ export function legacyParseFileSizeLimit(sizeStr: string): number { - return ramInBytes(sizeStr); + return parseStorageSizeBytes(sizeStr); } function isRecord(value: unknown): value is Record { diff --git a/apps/cli/src/next/commands/branches/switch/switch.handler.ts b/apps/cli/src/next/commands/branches/switch/switch.handler.ts index 24d76bbc96..ce529b3235 100644 --- a/apps/cli/src/next/commands/branches/switch/switch.handler.ts +++ b/apps/cli/src/next/commands/branches/switch/switch.handler.ts @@ -7,7 +7,7 @@ import { ProjectLinkState, ProjectNotLinkedError, } from "../../../config/project-link-state.service.ts"; -import { toStartStackConfig, withServiceVersions } from "../../../config/stack-config.ts"; +import { resolveStoredStackLaunch } from "../../../config/stack-config.ts"; import { NonInteractiveError } from "../../../../shared/output/errors.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; @@ -143,13 +143,13 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: { // so the local config reflects the branch's migrations and seed state. // `pull` does not exist yet. const launchConfig = Option.match(maybeMetadata, { - onNone: () => toStartStackConfig([], "auto"), + onNone: () => resolveStoredStackLaunch({ exclude: [], mode: "auto", runtimeVersions: {} }), onSome: (metadata) => { - const base = - metadata.launch !== undefined - ? toStartStackConfig(metadata.launch.excludedServices, metadata.launch.mode) - : toStartStackConfig([], "auto"); - return withServiceVersions(base, metadata.services); + return resolveStoredStackLaunch({ + exclude: metadata.launch?.excludedServices ?? [], + mode: metadata.launch?.mode ?? "auto", + runtimeVersions: metadata.services, + }); }, }); diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-config.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-config.ts index 144c71c390..2f7f82baec 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-config.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-config.ts @@ -1,13 +1,7 @@ -import { - inferFunctionsManifest, - loadDotEnvFile, - loadProjectConfig, - loadProjectEnvironment, - resolveProjectSubtree, -} from "@supabase/config"; -import type { ResolvedFunctionsBundle } from "@supabase/stack/effect"; -import { Effect, Option, Redacted } from "effect"; +import { loadProjectConfig, loadProjectEnvironment } from "@supabase/config"; +import { Effect, Option } from "effect"; import { basename, dirname, join, resolve } from "node:path"; +import { translateFunctionsDevStackConfig } from "../../../config/functions-stack-config.ts"; import { ProjectHome } from "../../../config/project-home.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; @@ -21,15 +15,6 @@ export interface FunctionsDevWatchPath { readonly names?: ReadonlyArray; } -function reveal(value: string | Redacted.Redacted): string { - return Redacted.isRedacted(value) ? Redacted.value(value) : value; -} - -function absoluteProjectPath(supabaseDir: string, path: string): string { - const withoutDotSlash = path.startsWith("./") ? path.slice(2) : path; - return resolve(supabaseDir, withoutDotSlash); -} - export const resolveFunctionsBundle = Effect.fnUntraced(function* ( opts: FunctionsDevConfigOptions, ) { @@ -40,59 +25,19 @@ export const resolveFunctionsBundle = Effect.fnUntraced(function* ( baseEnv: process.env, }); const loadedConfig = yield* loadProjectConfig(projectHome.projectRoot); - const projectConfig = - projectEnvironment === null || loadedConfig === null - ? undefined - : { - ...loadedConfig.config, - functions: Object.fromEntries( - Object.entries( - yield* resolveProjectSubtree( - loadedConfig.config.functions, - projectEnvironment, - "functions", - ), - ).map(([name, config]) => [ - name, - { - ...config, - entrypoint: reveal(config.entrypoint), - import_map: reveal(config.import_map), - static_files: config.static_files.map(reveal), - env: Object.fromEntries( - Object.entries(config.env).map(([key, value]) => [key, reveal(value)]), - ), - }, - ]), - ), - }; - const manifest = yield* inferFunctionsManifest({ - cwd: projectHome.projectRoot, - ...(projectConfig === undefined ? {} : { config: projectConfig }), - }); const envFilePath = Option.match(opts.envFile, { onNone: () => join(projectHome.supabaseDir, "functions", ".env"), onSome: (path) => resolve(runtimeInfo.cwd, path), }); - return { - env: yield* loadDotEnvFile(envFilePath), - functions: Object.entries(manifest) - .filter(([, config]) => config.enabled) - .map(([name, config]) => ({ - name, - verifyJWT: opts.noVerifyJwt ? false : config.verify_jwt, - entrypointPath: absoluteProjectPath(projectHome.supabaseDir, config.entrypoint), - importMapPath: - config.import_map === "" - ? null - : absoluteProjectPath(projectHome.supabaseDir, config.import_map), - staticFiles: config.static_files.map((path) => - absoluteProjectPath(projectHome.supabaseDir, path), - ), - env: config.env, - })), - } satisfies ResolvedFunctionsBundle; + return yield* translateFunctionsDevStackConfig({ + loadedProjectConfig: loadedConfig, + projectEnvironment, + projectRoot: projectHome.projectRoot, + configDir: projectHome.supabaseDir, + envFilePath, + noVerifyJwt: opts.noVerifyJwt, + }); }); export const functionsDevWatchPaths = Effect.fnUntraced(function* (envFile: Option.Option) { diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts index 0b5fe7d749..74939fc64c 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts @@ -17,7 +17,7 @@ import { resolveServiceVersionContext, type ResolvedServiceVersionContext, } from "../../../config/service-version-resolution.ts"; -import { toStartStackConfig, withServiceVersions } from "../../../config/stack-config.ts"; +import { resolveFunctionsDevStackLaunch } from "../../../config/stack-config.ts"; import { ensureProjectStateIgnored } from "../../../config/project-gitignore.ts"; import { Output } from "../../../../shared/output/output.service.ts"; import { @@ -53,7 +53,7 @@ interface FunctionsDevWatchChange { type StackService = typeof Stack.Service; function versionsFromContext(context: ResolvedServiceVersionContext) { - return withServiceVersions(toStartStackConfig([], "auto"), context.runtimeVersions); + return resolveFunctionsDevStackLaunch(context.runtimeVersions); } const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptions) { diff --git a/apps/cli/src/next/commands/start/start.command.ts b/apps/cli/src/next/commands/start/start.command.ts index 4b675d13e9..e210484c1d 100644 --- a/apps/cli/src/next/commands/start/start.command.ts +++ b/apps/cli/src/next/commands/start/start.command.ts @@ -5,6 +5,7 @@ import { StateManager, daemonLayer, stackMetadata, + type ResolvedFunctionsBundle, type StackMetadata, } from "@supabase/stack/effect"; import { Command, Flag } from "effect/unstable/cli"; @@ -13,6 +14,7 @@ import { projectLocalServiceVersionsLayer } from "../../config/project-local-ser import { ensureProjectStateIgnored } from "../../config/project-gitignore.ts"; import { CliConfig } from "../../config/cli-config.service.ts"; import { ProjectHome } from "../../config/project-home.service.ts"; +import { ProjectContext } from "../../config/project-context.service.ts"; import { projectLinkStateLayer } from "../../config/project-link-state.layer.ts"; import { provideProjectCommandRuntime } from "../../config/project-runtime.layer.ts"; import { @@ -22,10 +24,9 @@ import { import { excludedStackServices, type ExcludedStackService, + resolveLocalStackLaunch, startModes, type StartMode, - toStartStackConfig, - withServiceVersions, } from "../../config/stack-config.ts"; import { projectStackStateManagerLayer } from "../../config/project-stack-state-manager.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -36,31 +37,6 @@ import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import { withCommandInstrumentation } from "../../../shared/telemetry/command-instrumentation.ts"; import { start } from "./start.handler.ts"; -/** - * Deprecation warning shown when `[api].auto_expose_new_tables = true` is loaded from - * config.toml. Mirrors the Go CLI warning emitted during config validation - * (`apps/cli-go/pkg/config/config.go`). - */ -export const AUTO_EXPOSE_NEW_TABLES_DEPRECATION_WARNING = - "api.auto_expose_new_tables is deprecated and will be removed on 2026-10-30. Remove the field or set it to false to adopt the new default of revoking Data API privileges on new entities in the public schema."; - -/** - * Resolves the tri-state `[api].auto_expose_new_tables` flag from config.toml. - * - * - unset (`undefined`): defaults to `false` (revoke), matching the 2026-05-30 cloud flip. - * - `true`: keep the legacy auto-expose behaviour, but surface a deprecation warning. - * - `false`: revoke explicitly (no warning). - */ -export function resolveAutoExposeNewTables(value: boolean | undefined): { - readonly autoExposeNewTables: boolean; - readonly deprecationWarning: string | undefined; -} { - return { - autoExposeNewTables: value ?? false, - deprecationWarning: value === true ? AUTO_EXPOSE_NEW_TABLES_DEPRECATION_WARNING : undefined, - }; -} - export const excludeFlag = Flag.choice("exclude", excludedStackServices).pipe( Flag.atMost(excludedStackServices.length), Flag.withDescription( @@ -93,6 +69,15 @@ export class StartVersionState extends Context.Service()("supabase/commands/start/StartFunctionsState") {} + const flags = { stack: Flag.string("stack").pipe( Flag.withDescription("Name of the managed local stack for this project."), @@ -161,6 +146,7 @@ export const startCommand = Command.make("start", flags).pipe( const output = yield* Output; const cliConfig = yield* CliConfig; const projectHome = yield* ProjectHome; + const projectContext = yield* ProjectContext; const runtimeInfo = yield* RuntimeInfo; const stateManager = yield* StateManager; const existingMetadata = yield* stateManager.readMetadata(flags.stack).pipe( @@ -174,35 +160,34 @@ export const startCommand = Command.make("start", flags).pipe( onSome: (metadata) => metadata.services, }), ); - // The flag is tri-state in config.toml: unset / true / false. As of the 2026-05-30 flip, - // unset behaves as false (revoke the default Data API GRANTs) to match the new cloud - // default. Explicit true preserves the legacy auto-expose behaviour but is deprecated and - // emits a warning; the field is removed entirely on 2026-10-30. - const loadedProjectConfig = yield* loadProjectConfig(projectHome.projectRoot); - const { autoExposeNewTables, deprecationWarning } = resolveAutoExposeNewTables( - loadedProjectConfig?.config.api.auto_expose_new_tables, + const projectEnvironment = Option.getOrNull(projectContext.projectEnv); + const loadedProjectConfig = yield* loadProjectConfig( + projectHome.projectRoot, + projectEnvironment === null ? undefined : { projectEnv: projectEnvironment }, ); - if (deprecationWarning !== undefined) { - yield* output.warn(deprecationWarning); + const launch = yield* resolveLocalStackLaunch({ + loadedProjectConfig, + projectEnvironment, + projectPaths: { + projectRoot: projectHome.projectRoot, + projectStateRoot: projectHome.projectHomeDir, + }, + mode: flags.mode, + exclude: flags.exclude, + runtimeVersions: serviceVersionContext.runtimeVersions, + }); + for (const warning of launch.warnings) { + yield* output.warn(warning.message); } - const baseStackConfig = withServiceVersions( - toStartStackConfig(flags.exclude, flags.mode), - serviceVersionContext.runtimeVersions, - ); - const stackConfig = { - ...baseStackConfig, - postgres: { ...baseStackConfig.postgres, autoExposeNewTables }, - }; yield* output.intro("Start local Supabase stack"); yield* ensureProjectStateIgnored(projectHome.projectRoot); const stackLayer = yield* daemonLayer({ cacheRoot: cliConfig.supabaseHome, cwd: runtimeInfo.cwd, - projectDir: projectHome.projectRoot, projectStateRoot: projectHome.projectHomeDir, name: flags.stack, - ...stackConfig, + ...launch.stackConfig, }); const daemonState = yield* stateManager.read(flags.stack); @@ -222,6 +207,7 @@ export const startCommand = Command.make("start", flags).pipe( return { stackLayer, + startFunctionsState: StartFunctionsState.of({ bundle: launch.functionsBundle }), startVersionState: StartVersionState.of({ metadata, serviceVersionContext, @@ -231,8 +217,12 @@ export const startCommand = Command.make("start", flags).pipe( const commandLayer = Layer.unwrap( runtimeStateEffect.pipe( - Effect.map(({ stackLayer, startVersionState }) => - Layer.mergeAll(stackLayer, Layer.succeed(StartVersionState, startVersionState)), + Effect.map(({ stackLayer, startFunctionsState, startVersionState }) => + Layer.mergeAll( + stackLayer, + Layer.succeed(StartFunctionsState, startFunctionsState), + Layer.succeed(StartVersionState, startVersionState), + ), ), Effect.provide(providedRuntimeLayer), ), diff --git a/apps/cli/src/next/commands/start/start.command.unit.test.ts b/apps/cli/src/next/commands/start/start.command.unit.test.ts index e326938db5..773c310d8c 100644 --- a/apps/cli/src/next/commands/start/start.command.unit.test.ts +++ b/apps/cli/src/next/commands/start/start.command.unit.test.ts @@ -1,12 +1,7 @@ import { describe, expect, test } from "vitest"; import { BunServices } from "@effect/platform-bun"; import { Effect, Exit } from "effect"; -import { - AUTO_EXPOSE_NEW_TABLES_DEPRECATION_WARNING, - excludeFlag, - resolveAutoExposeNewTables, - serviceVersionFlag, -} from "./start.command.ts"; +import { excludeFlag, serviceVersionFlag } from "./start.command.ts"; describe("start command exclude flag", () => { test("parses repeated excluded services", async () => { @@ -49,26 +44,3 @@ describe("start command exclude flag", () => { expect(overrides).toEqual(["auth=v2.180.0", "postgres=17.4.1.045"]); }); }); - -describe("resolveAutoExposeNewTables", () => { - test("defaults to false (revoke) when the flag is unset", () => { - expect(resolveAutoExposeNewTables(undefined)).toEqual({ - autoExposeNewTables: false, - deprecationWarning: undefined, - }); - }); - - test("keeps legacy auto-expose behaviour and warns when explicitly true", () => { - expect(resolveAutoExposeNewTables(true)).toEqual({ - autoExposeNewTables: true, - deprecationWarning: AUTO_EXPOSE_NEW_TABLES_DEPRECATION_WARNING, - }); - }); - - test("revokes without warning when explicitly false", () => { - expect(resolveAutoExposeNewTables(false)).toEqual({ - autoExposeNewTables: false, - deprecationWarning: undefined, - }); - }); -}); diff --git a/apps/cli/src/next/commands/start/start.handler.ts b/apps/cli/src/next/commands/start/start.handler.ts index 6925658139..421664ddda 100644 --- a/apps/cli/src/next/commands/start/start.handler.ts +++ b/apps/cli/src/next/commands/start/start.handler.ts @@ -1,9 +1,9 @@ import { Effect } from "effect"; -import { StateManager, stackMetadata } from "@supabase/stack/effect"; +import { Stack, StateManager, stackMetadata } from "@supabase/stack/effect"; import { Output } from "../../../shared/output/output.service.ts"; import { Analytics } from "../../../shared/telemetry/analytics.service.ts"; import type { StartFlags } from "./start.command.ts"; -import { StartVersionState } from "./start.command.ts"; +import { StartFunctionsState, StartVersionState } from "./start.command.ts"; import { startBackground } from "./flows/background.flow.ts"; import { startForeground } from "./flows/foreground.flow.ts"; import { startNonInteractive } from "./flows/non-interactive.flow.ts"; @@ -13,7 +13,9 @@ export const start = Effect.fnUntraced(function* (flags: StartFlags) { Effect.gen(function* () { const output = yield* Output; const analytics = yield* Analytics; + const stack = yield* Stack; const stateManager = yield* StateManager; + const functionsState = yield* StartFunctionsState; const startVersionState = yield* StartVersionState; const { metadata, serviceVersionContext } = startVersionState; @@ -55,6 +57,10 @@ export const start = Effect.fnUntraced(function* (flags: StartFlags) { ); } + if (functionsState.bundle !== undefined) { + yield* stack.configureFunctions({ functions: functionsState.bundle }); + } + let result: void; if (flags.detach) { result = yield* startBackground(); diff --git a/apps/cli/src/next/commands/start/start.integration.test.ts b/apps/cli/src/next/commands/start/start.integration.test.ts index c1259a74bf..edbf51cfbd 100644 --- a/apps/cli/src/next/commands/start/start.integration.test.ts +++ b/apps/cli/src/next/commands/start/start.integration.test.ts @@ -5,10 +5,16 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { Deferred, Effect, Exit, Fiber, Layer } from "effect"; import type { StackServiceStatus } from "@supabase/stack"; -import { DEFAULT_VERSIONS, stackMetadata, type StackInfo } from "@supabase/stack/effect"; -import { loadProjectConfig } from "@supabase/config"; +import { + DEFAULT_VERSIONS, + stackMetadata, + type ResolvedFunctionsBundle, + type StackInfo, +} from "@supabase/stack/effect"; +import { loadProjectConfig, loadProjectEnvironment } from "@supabase/config"; +import { resolveLocalStackLaunch } from "../../config/stack-config.ts"; import { start } from "./start.handler.ts"; -import { StartVersionState } from "./start.command.ts"; +import { StartFunctionsState, StartVersionState } from "./start.command.ts"; import { startForegroundWithStopSignal } from "./flows/foreground.flow.ts"; import type { ResolvedServiceVersionContext } from "../../config/service-version-resolution.ts"; import { @@ -140,6 +146,10 @@ function mockStartVersionState( ); } +function mockStartFunctionsState(bundle?: ResolvedFunctionsBundle) { + return Layer.succeed(StartFunctionsState, StartFunctionsState.of({ bundle })); +} + function setupInteractive( opts: { info?: Partial; @@ -162,6 +172,7 @@ function setupInteractive( analytics.layer, out.layer, ink.layer, + mockStartFunctionsState(), mockStartVersionState(), ); return { layer, stack, out, ink, analytics }; @@ -173,6 +184,7 @@ function setupNonInteractive( stateChanges?: Array<{ name: string; status: StackServiceStatus }>; startPending?: boolean; liveStateChanges?: boolean; + functionsBundle?: ResolvedFunctionsBundle; } = {}, ) { const stack = mockStack({ @@ -190,6 +202,7 @@ function setupNonInteractive( analytics.layer, out.layer, ink.layer, + mockStartFunctionsState(opts.functionsBundle), mockStartVersionState(), ); return { layer, stack, out, ink, analytics }; @@ -210,6 +223,49 @@ const waitFor = Effect.fnUntraced(function* ( }); describe("start", () => { + it.live("starts an empty project without activating Functions reload behavior", () => { + const { layer, stack } = setupNonInteractive(); + + return Effect.gen(function* () { + yield* start(backgroundFlags); + + expect(stack.started).toBe(true); + expect(stack.functionsConfigurations).toEqual([]); + expect(stack.functionsReloads).toEqual([]); + }).pipe(Effect.provide(layer)); + }); + + it.live("configures the resolved Functions bundle before detached startup", () => { + const bundle: ResolvedFunctionsBundle = { + env: { SHARED_SECRET: "private-shared-value" }, + functions: [ + { + name: "hello", + verifyJWT: false, + entrypointPath: "/project/supabase/functions/hello/index.ts", + importMapPath: null, + staticFiles: [], + env: { FUNCTION_SECRET: "private-function-value" }, + }, + ], + }; + const { layer, stack, out, analytics } = setupNonInteractive({ functionsBundle: bundle }); + + return Effect.gen(function* () { + yield* start(backgroundFlags); + + expect(stack.functionsConfigurations).toEqual([{ functions: bundle }]); + expect(stack.functionsReloads).toEqual([]); + expect(stack.operations.slice(0, 2)).toEqual(["configure-functions", "start"]); + expect( + JSON.stringify({ messages: out.messages, analytics: analytics.captured }), + ).not.toContain("private-shared-value"); + expect( + JSON.stringify({ messages: out.messages, analytics: analytics.captured }), + ).not.toContain("private-function-value"); + }).pipe(Effect.provide(layer)); + }); + it.live("runs detached mode in the background and prints connection info", () => { const { layer, stack, out, ink, analytics } = setupNonInteractive(); return Effect.gen(function* () { @@ -388,6 +444,7 @@ describe("start", () => { analytics.layer, out.layer, ink.layer, + mockStartFunctionsState(), mockStartVersionState({ metadata: stackMetadata({ ports: { @@ -516,6 +573,7 @@ describe("start", () => { analytics.layer, out.layer, ink.layer, + mockStartFunctionsState(), mockStartVersionState({ serviceVersionContext: { activeOverrides: [{ service: "storage", version: "1.40.0", source: "local" }], @@ -556,6 +614,7 @@ describe("start", () => { analytics.layer, out.layer, ink.layer, + mockStartFunctionsState(), mockStartVersionState({ serviceVersionContext: { activeOverrides: [{ service: "auth", version: "2.180.0", source: "flag" }], @@ -641,4 +700,47 @@ project_id = "not-a-ref" await rm(tempDir, { recursive: true, force: true }); } }); + + it("hands resolved database bootstrap inputs to the stack launch", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "supabase-next-start-bootstrap-")); + try { + await mkdir(join(projectRoot, "supabase"), { recursive: true }); + const seed = join(projectRoot, "supabase", "seed.sql"); + await writeFile(seed, "insert into start_bootstrap values (1);"); + await writeFile( + join(projectRoot, "supabase", ".env.local"), + "SUPABASE_DB_MIGRATIONS_ENABLED=false\n", + ); + await writeFile( + join(projectRoot, "supabase", "config.toml"), + ["[db.seed]", "enabled = true", 'sql_paths = ["./seed.sql"]'].join("\n"), + ); + + const launch = await Effect.runPromise( + Effect.gen(function* () { + const projectEnvironment = yield* loadProjectEnvironment({ cwd: projectRoot }); + if (projectEnvironment === null) { + return yield* Effect.die("expected a project environment"); + } + const loadedProjectConfig = yield* loadProjectConfig(projectRoot, { + projectEnv: projectEnvironment, + }); + return yield* resolveLocalStackLaunch({ + loadedProjectConfig, + projectEnvironment, + projectPaths: { projectRoot, projectStateRoot: join(projectRoot, ".supabase") }, + mode: "auto", + exclude: [], + runtimeVersions: {}, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + expect(launch.stackConfig.databaseBootstrap?.seedFiles?.map(({ path }) => path)).toEqual([ + seed, + ]); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); }); diff --git a/apps/cli/src/next/commands/update/update.handler.ts b/apps/cli/src/next/commands/update/update.handler.ts index 3784ad0255..6a9bc9b9c2 100644 --- a/apps/cli/src/next/commands/update/update.handler.ts +++ b/apps/cli/src/next/commands/update/update.handler.ts @@ -10,7 +10,7 @@ import { } from "../../config/project-link-remote.service.ts"; import { ProjectLinkState } from "../../config/project-link-state.service.ts"; import { resolveServiceVersionContext } from "../../config/service-version-resolution.ts"; -import { toStartStackConfig, withServiceVersions } from "../../config/stack-config.ts"; +import { resolveStoredStackLaunch } from "../../config/stack-config.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { RuntimeInfo } from "../../../shared/runtime/runtime-info.service.ts"; import type { UpdateFlags } from "./update.command.ts"; @@ -100,10 +100,11 @@ export const update = Effect.fnUntraced(function* (flags: UpdateFlags) { projectDir: projectHome.projectRoot, projectStateRoot: projectHome.projectHomeDir, name: flags.stack, - ...withServiceVersions( - toStartStackConfig(persistedLaunch.excludedServices, persistedLaunch.mode), - serviceVersionContext.candidateBaseline, - ), + ...resolveStoredStackLaunch({ + exclude: persistedLaunch.excludedServices, + mode: persistedLaunch.mode, + runtimeVersions: serviceVersionContext.candidateBaseline, + }), }), ); diff --git a/apps/cli/src/next/config/analytics-stack-config.ts b/apps/cli/src/next/config/analytics-stack-config.ts new file mode 100644 index 0000000000..a52ea69789 --- /dev/null +++ b/apps/cli/src/next/config/analytics-stack-config.ts @@ -0,0 +1,75 @@ +import type { LoadedProjectConfig, ProjectConfig, ProjectEnvironment } from "@supabase/config"; +import type { AnalyticsConfig } from "@supabase/stack/effect"; +import { resolve } from "node:path"; +import { + environmentOverride, + invalidDataPlaneConfig, + resolveBooleanOverride, + resolveEnumOverride, +} from "./data-plane-stack-config-values.ts"; + +function required(value: string | undefined, path: string): string { + if (value === undefined || value.length === 0 || /^env\([^)]+\)$/.test(value)) { + throw invalidDataPlaneConfig(path, "Provide a non-empty value when Analytics uses BigQuery."); + } + return value; +} + +export function resolveAnalyticsStackConfig(input: { + readonly loaded: LoadedProjectConfig | null; + readonly config: ProjectConfig["analytics"]; + readonly environment: ProjectEnvironment | null; + readonly configDir: string; + readonly base: AnalyticsConfig | false | undefined; +}): AnalyticsConfig | false { + const enabled = resolveBooleanOverride({ + loaded: input.loaded, + environment: input.environment, + configured: input.config.enabled, + path: "analytics.enabled", + }); + const backend = resolveEnumOverride<"postgres" | "bigquery">({ + loaded: input.loaded, + environment: input.environment, + configured: input.config.backend, + path: "analytics.backend", + values: ["postgres", "bigquery"], + }); + const gcp = + enabled && backend === "bigquery" + ? { + projectId: required( + environmentOverride( + "analytics.gcp_project_id", + input.config.gcp_project_id, + input.environment, + input.loaded, + ), + "analytics.gcp_project_id", + ), + projectNumber: required( + environmentOverride( + "analytics.gcp_project_number", + input.config.gcp_project_number, + input.environment, + input.loaded, + ), + "analytics.gcp_project_number", + ), + credentialsPath: resolve( + input.configDir, + required( + environmentOverride( + "analytics.gcp_jwt_path", + input.config.gcp_jwt_path, + input.environment, + input.loaded, + ), + "analytics.gcp_jwt_path", + ), + ), + } + : undefined; + + return input.base === false ? false : { ...input.base, backend, gcp }; +} diff --git a/apps/cli/src/next/config/auth-stack-config.ts b/apps/cli/src/next/config/auth-stack-config.ts new file mode 100644 index 0000000000..b682aeb0dd --- /dev/null +++ b/apps/cli/src/next/config/auth-stack-config.ts @@ -0,0 +1,654 @@ +import type { LoadedProjectConfig, ProjectConfig, ProjectEnvironment } from "@supabase/config"; +import { + defaultJwtSecret, + type AuthConfig, + type AuthExternalProviderConfig, + type AuthHookConfig, + type AuthSmsConfig, + type LocalCredentials, + type LocalJwtSigningKey, + type LocalJwtSigningMaterial, + type PasswordRequirements, + validateLocalJwtSigningKeys, +} from "@supabase/stack/effect"; +import { Data, Effect, Schema } from "effect"; +import { readFile } from "node:fs/promises"; +import { isAbsolute, join } from "node:path"; +import { + effectiveEnvironmentOverride, + effectiveStringList, + parseGoBoolean, + resolveEnvironmentReference, +} from "./local-stack-config-values.ts"; + +export class AuthStackConfigError extends Data.TaggedError("AuthStackConfigError")<{ + readonly path: string; + readonly detail: string; + readonly suggestion: string; +}> {} + +const LocalJwtSigningKeySchema = Schema.Struct({ + kty: Schema.String, + kid: Schema.optionalKey(Schema.String), + use: Schema.optionalKey(Schema.String), + key_ops: Schema.optionalKey(Schema.Array(Schema.String)), + alg: Schema.optionalKey(Schema.String), + ext: Schema.optionalKey(Schema.Boolean), + n: Schema.optionalKey(Schema.String), + e: Schema.optionalKey(Schema.String), + d: Schema.optionalKey(Schema.String), + p: Schema.optionalKey(Schema.String), + q: Schema.optionalKey(Schema.String), + dp: Schema.optionalKey(Schema.String), + dq: Schema.optionalKey(Schema.String), + qi: Schema.optionalKey(Schema.String), + crv: Schema.optionalKey(Schema.String), + x: Schema.optionalKey(Schema.String), + y: Schema.optionalKey(Schema.String), +}); +const decodeSigningKeys = Schema.decodeUnknownSync(Schema.Array(LocalJwtSigningKeySchema)); + +function missingRequired(path: string): AuthStackConfigError { + return new AuthStackConfigError({ + path, + detail: `Auth configuration is incomplete at ${path}.`, + suggestion: "Provide the required project configuration value; use env() for secrets.", + }); +} + +function required(value: string | undefined, path: string): string { + if (value === undefined || value.length === 0) throw missingRequired(path); + return value; +} + +function requiredNumber(value: number | undefined, path: string): number { + if (value === undefined) throw missingRequired(path); + return value; +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function invalidOverride(path: string, suggestion: string): AuthStackConfigError { + return new AuthStackConfigError({ + path, + detail: `Invalid Auth environment override at ${path}.`, + suggestion, + }); +} + +function envBoolean(input: { + readonly loaded: LoadedProjectConfig | null; + readonly environment: ProjectEnvironment | null; + readonly configured: boolean; + readonly path: string; + readonly enabled?: boolean; +}): boolean { + if (input.enabled === false) return input.configured; + const value = effectiveEnvironmentOverride(input); + if (value === undefined) return input.configured; + const parsed = parseGoBoolean(value); + if (parsed === undefined) { + throw invalidOverride(input.path, "Use a Go-compatible boolean such as true, false, 1, or 0."); + } + return parsed; +} + +function parseGoUnsigned(value: string): number | undefined { + const signless = value.startsWith("+") ? value.slice(1) : value; + if (signless.length === 0 || signless.startsWith("-")) return undefined; + let base = 10; + let digits = signless; + if (/^0[xX]/.test(signless)) { + base = 16; + digits = signless.slice(2); + } else if (/^0[oO]/.test(signless)) { + base = 8; + digits = signless.slice(2); + } else if (/^0[0-7]+$/.test(signless)) { + base = 8; + digits = signless.slice(1); + } + if (digits.length === 0) return undefined; + const validDigits = base === 16 ? /^[0-9a-fA-F]+$/ : base === 8 ? /^[0-7]+$/ : /^[0-9]+$/; + if (!validDigits.test(digits)) return undefined; + const parsed = Number.parseInt(digits, base); + return Number.isSafeInteger(parsed) ? parsed : undefined; +} + +function envNumber(input: { + readonly loaded: LoadedProjectConfig | null; + readonly environment: ProjectEnvironment | null; + readonly configured: number | undefined; + readonly path: string; + readonly max?: number; + readonly enabled?: boolean; +}): number | undefined { + if (input.enabled === false) return input.configured; + const value = effectiveEnvironmentOverride(input); + if (value === undefined) return input.configured; + const parsed = parseGoUnsigned(value); + if (parsed === undefined || (input.max !== undefined && parsed > input.max)) { + throw invalidOverride(input.path, "Use a non-negative integer in the supported range."); + } + return parsed; +} + +function envString(input: { + readonly loaded: LoadedProjectConfig | null; + readonly environment: ProjectEnvironment | null; + readonly path: string; + readonly configured: string | undefined; + readonly enabled?: boolean; +}): string | undefined { + if (input.enabled === false) return input.configured; + const value = effectiveEnvironmentOverride(input) ?? input.configured; + return value === undefined ? undefined : resolveEnvironmentReference(value, input.environment); +} + +function envList(input: { + readonly loaded: LoadedProjectConfig | null; + readonly environment: ProjectEnvironment | null; + readonly path: string; + readonly configured: ReadonlyArray; +}): ReadonlyArray { + return effectiveStringList(input); +} + +function envStringMap(input: { + readonly loaded: LoadedProjectConfig | null; + readonly environment: ProjectEnvironment | null; + readonly path: string; + readonly configured: Readonly> | undefined; +}): Readonly> | undefined { + if (effectiveEnvironmentOverride(input) === undefined) return input.configured; + throw invalidOverride( + input.path, + "Configure this string map in config.toml; a single environment string cannot decode to it.", + ); +} + +function resolvePasswordRequirements(value: string): PasswordRequirements { + switch (value) { + case "": + case "letters_digits": + case "lower_upper_letters_digits": + case "lower_upper_letters_digits_symbols": + return value; + default: + throw new AuthStackConfigError({ + path: "auth.password_requirements", + detail: "The configured Auth password requirements are not supported.", + suggestion: "Use one of the password requirement policies accepted by project config.", + }); + } +} + +function resolveSmsProvider(input: { + readonly sms: ProjectConfig["auth"]["sms"]; + readonly authDocument: Readonly> | undefined; + readonly loaded: LoadedProjectConfig | null; + readonly environment: ProjectEnvironment | null; +}): AuthSmsConfig["provider"] { + const smsDocument = isRecord(input.authDocument?.sms) ? input.authDocument.sms : undefined; + const providerPresent = (name: string) => name === "twilio" || isRecord(smsDocument?.[name]); + const enabled = (name: string, configured: boolean) => + envBoolean({ + loaded: input.loaded, + environment: input.environment, + configured, + path: `auth.sms.${name}.enabled`, + enabled: providerPresent(name), + }); + const providerString = ( + name: string, + field: string, + configured: string | undefined, + ): string | undefined => + envString({ + loaded: input.loaded, + environment: input.environment, + path: `auth.sms.${name}.${field}`, + configured, + enabled: providerPresent(name), + }); + const { sms } = input; + if (enabled("twilio", sms.twilio.enabled)) { + return { + _tag: "twilio", + accountSid: + providerString("twilio", "account_sid", sms.twilio.account_sid) ?? sms.twilio.account_sid, + messageServiceSid: + providerString("twilio", "message_service_sid", sms.twilio.message_service_sid) ?? + sms.twilio.message_service_sid, + authToken: required( + providerString("twilio", "auth_token", sms.twilio.auth_token), + "auth.sms.twilio.auth_token", + ), + }; + } + if (enabled("twilio_verify", sms.twilio_verify.enabled)) { + return { + _tag: "twilio-verify", + accountSid: required( + providerString("twilio_verify", "account_sid", sms.twilio_verify.account_sid), + "auth.sms.twilio_verify.account_sid", + ), + messageServiceSid: required( + providerString( + "twilio_verify", + "message_service_sid", + sms.twilio_verify.message_service_sid, + ), + "auth.sms.twilio_verify.message_service_sid", + ), + authToken: required( + providerString("twilio_verify", "auth_token", sms.twilio_verify.auth_token), + "auth.sms.twilio_verify.auth_token", + ), + }; + } + if (enabled("messagebird", sms.messagebird.enabled)) { + return { + _tag: "messagebird", + originator: required( + providerString("messagebird", "originator", sms.messagebird.originator), + "auth.sms.messagebird.originator", + ), + accessKey: required( + providerString("messagebird", "access_key", sms.messagebird.access_key), + "auth.sms.messagebird.access_key", + ), + }; + } + if (enabled("textlocal", sms.textlocal.enabled)) { + return { + _tag: "textlocal", + sender: required( + providerString("textlocal", "sender", sms.textlocal.sender), + "auth.sms.textlocal.sender", + ), + apiKey: required( + providerString("textlocal", "api_key", sms.textlocal.api_key), + "auth.sms.textlocal.api_key", + ), + }; + } + if (enabled("vonage", sms.vonage.enabled)) { + return { + _tag: "vonage", + from: required(providerString("vonage", "from", sms.vonage.from), "auth.sms.vonage.from"), + apiKey: required( + providerString("vonage", "api_key", sms.vonage.api_key), + "auth.sms.vonage.api_key", + ), + apiSecret: required( + providerString("vonage", "api_secret", sms.vonage.api_secret), + "auth.sms.vonage.api_secret", + ), + }; + } + return undefined; +} + +function resolveExternalProviders(input: { + readonly external: ProjectConfig["auth"]["external"]; + readonly authDocument: Readonly> | undefined; + readonly loaded: LoadedProjectConfig | null; + readonly environment: ProjectEnvironment | null; +}): Readonly> { + const externalDocument = isRecord(input.authDocument?.external) + ? input.authDocument.external + : undefined; + return Object.fromEntries( + Object.entries(input.external).map(([name, provider]) => { + const sectionPresent = name === "apple" || isRecord(externalDocument?.[name]); + const stringField = (field: string, configured: string | undefined) => + envString({ + loaded: input.loaded, + environment: input.environment, + path: `auth.external.${name}.${field}`, + configured, + enabled: sectionPresent, + }); + const booleanField = (field: string, configured: boolean) => + envBoolean({ + loaded: input.loaded, + environment: input.environment, + configured, + path: `auth.external.${name}.${field}`, + enabled: sectionPresent, + }); + return [ + name, + { + enabled: booleanField("enabled", provider.enabled), + clientId: stringField("client_id", provider.client_id) ?? provider.client_id, + secret: stringField("secret", provider.secret), + url: stringField("url", provider.url) ?? provider.url, + redirectUri: stringField("redirect_uri", provider.redirect_uri), + skipNonceCheck: booleanField("skip_nonce_check", provider.skip_nonce_check), + emailOptional: booleanField("email_optional", provider.email_optional), + }, + ]; + }), + ); +} + +function resolveHooks(input: { + readonly hooks: ProjectConfig["auth"]["hook"]; + readonly authDocument: Readonly> | undefined; + readonly loaded: LoadedProjectConfig | null; + readonly environment: ProjectEnvironment | null; +}): Readonly> { + const hookDocument = isRecord(input.authDocument?.hook) ? input.authDocument.hook : undefined; + return Object.fromEntries( + Object.entries(input.hooks).map(([name, hook]) => { + const sectionPresent = isRecord(hookDocument?.[name]); + return [ + name, + { + enabled: envBoolean({ + loaded: input.loaded, + environment: input.environment, + configured: hook.enabled, + path: `auth.hook.${name}.enabled`, + enabled: sectionPresent, + }), + uri: envString({ + loaded: input.loaded, + environment: input.environment, + path: `auth.hook.${name}.uri`, + configured: hook.uri, + enabled: sectionPresent, + }), + secrets: envString({ + loaded: input.loaded, + environment: input.environment, + path: `auth.hook.${name}.secrets`, + configured: hook.secrets, + enabled: sectionPresent, + }), + }, + ]; + }), + ); +} + +function decodeSigningKeyFile( + contents: string, +): readonly [LocalJwtSigningKey, ...ReadonlyArray] { + const decoded = decodeSigningKeys(JSON.parse(contents)).map((key) => ({ + ...key, + key_ops: key.key_ops === undefined ? undefined : [...key.key_ops], + })); + const [first, ...rest] = decoded; + if (first === undefined) { + throw new Error("signing key file must contain at least one key"); + } + const keys = [first, ...rest]; + validateLocalJwtSigningKeys(keys); + return keys; +} + +function readSigningKeys( + configDir: string, + configuredPath: string, +): Effect.Effect< + readonly [LocalJwtSigningKey, ...ReadonlyArray], + AuthStackConfigError +> { + const path = isAbsolute(configuredPath) ? configuredPath : join(configDir, configuredPath); + return Effect.tryPromise({ + try: async () => decodeSigningKeyFile(await readFile(path, "utf8")), + catch: () => + new AuthStackConfigError({ + path: "auth.signing_keys_path", + detail: "Unable to read or validate the configured Auth signing keys.", + suggestion: "Provide a readable JSON array containing at least one RS256 or ES256 key.", + }), + }); +} + +interface TranslatedAuthStackConfig { + readonly auth: AuthConfig | false; + readonly credentials: LocalCredentials; +} + +export const translateAuthStackConfig = Effect.fnUntraced(function* (input: { + readonly projectConfig: ProjectConfig; + readonly loadedProjectConfig: LoadedProjectConfig | null; + readonly projectEnvironment: ProjectEnvironment | null; + readonly configDir: string; + readonly authEnabled: boolean; +}) { + const { auth } = input.projectConfig; + const authDocument = isRecord(input.loadedProjectConfig?.document?.auth) + ? input.loadedProjectConfig.document.auth + : undefined; + const authEnabled = input.authEnabled; + const flatString = (field: string, configured: string | undefined) => + envString({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + path: `auth.${field}`, + configured, + }); + const flatBoolean = (field: string, configured: boolean) => + envBoolean({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + configured, + path: `auth.${field}`, + }); + const flatNumber = (field: string, configured: number) => + envNumber({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + configured, + path: `auth.${field}`, + }) ?? configured; + const jwtSecret = flatString("jwt_secret", auth.jwt_secret) ?? defaultJwtSecret; + const signingKeysPath = flatString("signing_keys_path", auth.signing_keys_path); + let signing: LocalJwtSigningMaterial; + if (signingKeysPath !== undefined && signingKeysPath.length > 0) { + signing = { + _tag: "AsymmetricJwtKeys", + keys: yield* readSigningKeys(input.configDir, signingKeysPath), + legacySecret: jwtSecret, + }; + } else { + signing = { _tag: "SymmetricJwtSecret", secret: jwtSecret }; + } + + const credentials: LocalCredentials = { + signing, + publishableKey: flatString("publishable_key", auth.publishable_key), + secretKey: flatString("secret_key", auth.secret_key), + anonKey: flatString("anon_key", auth.anon_key), + serviceRoleKey: flatString("service_role_key", auth.service_role_key), + }; + + if (!authEnabled) return { auth: false, credentials } satisfies TranslatedAuthStackConfig; + + const smtpDocument = isRecord(authDocument?.email) + ? isRecord(authDocument.email.smtp) + ? authDocument.email.smtp + : undefined + : undefined; + const smtpPresent = smtpDocument !== undefined; + const smtpEnabled = + smtpPresent && + envBoolean({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + configured: smtpDocument.enabled === undefined ? true : auth.email.smtp?.enabled === true, + path: "auth.email.smtp.enabled", + }); + const smtpString = (field: string, configured: string | undefined) => + envString({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + path: `auth.email.smtp.${field}`, + configured, + enabled: smtpPresent, + }); + const smtp = smtpEnabled + ? { + host: required(smtpString("host", auth.email.smtp?.host), "auth.email.smtp.host"), + port: requiredNumber( + envNumber({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + configured: auth.email.smtp?.port, + path: "auth.email.smtp.port", + max: 65_535, + enabled: smtpPresent, + }), + "auth.email.smtp.port", + ), + user: required(smtpString("user", auth.email.smtp?.user), "auth.email.smtp.user"), + pass: required(smtpString("pass", auth.email.smtp?.pass), "auth.email.smtp.pass"), + adminEmail: required( + smtpString("admin_email", auth.email.smtp?.admin_email), + "auth.email.smtp.admin_email", + ), + senderName: smtpString("sender_name", auth.email.smtp?.sender_name), + } + : undefined; + + return { + credentials, + auth: { + siteUrl: flatString("site_url", auth.site_url) ?? auth.site_url, + additionalRedirectUrls: envList({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + path: "auth.additional_redirect_urls", + configured: auth.additional_redirect_urls, + }), + jwtExpiry: flatNumber("jwt_expiry", auth.jwt_expiry), + jwtIssuer: flatString("jwt_issuer", auth.jwt_issuer), + enableSignup: flatBoolean("enable_signup", auth.enable_signup), + enableAnonymousSignIns: flatBoolean( + "enable_anonymous_sign_ins", + auth.enable_anonymous_sign_ins, + ), + enableRefreshTokenRotation: flatBoolean( + "enable_refresh_token_rotation", + auth.enable_refresh_token_rotation, + ), + refreshTokenReuseInterval: flatNumber( + "refresh_token_reuse_interval", + auth.refresh_token_reuse_interval, + ), + enableManualLinking: flatBoolean("enable_manual_linking", auth.enable_manual_linking), + minimumPasswordLength: flatNumber("minimum_password_length", auth.minimum_password_length), + passwordRequirements: resolvePasswordRequirements( + flatString("password_requirements", auth.password_requirements) ?? + auth.password_requirements, + ), + email: { + enableSignup: envBoolean({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + configured: auth.email.enable_signup, + path: "auth.email.enable_signup", + }), + doubleConfirmChanges: envBoolean({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + configured: auth.email.double_confirm_changes, + path: "auth.email.double_confirm_changes", + }), + enableConfirmations: envBoolean({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + configured: auth.email.enable_confirmations, + path: "auth.email.enable_confirmations", + }), + securePasswordChange: envBoolean({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + configured: auth.email.secure_password_change, + path: "auth.email.secure_password_change", + }), + maxFrequency: + envString({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + path: "auth.email.max_frequency", + configured: auth.email.max_frequency, + }) ?? auth.email.max_frequency, + otpLength: + envNumber({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + configured: auth.email.otp_length, + path: "auth.email.otp_length", + }) ?? auth.email.otp_length, + otpExpiry: + envNumber({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + configured: auth.email.otp_expiry, + path: "auth.email.otp_expiry", + }) ?? auth.email.otp_expiry, + smtp, + }, + sms: { + enableSignup: envBoolean({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + configured: auth.sms.enable_signup, + path: "auth.sms.enable_signup", + }), + enableConfirmations: envBoolean({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + configured: auth.sms.enable_confirmations, + path: "auth.sms.enable_confirmations", + }), + template: + envString({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + path: "auth.sms.template", + configured: auth.sms.template, + }) ?? auth.sms.template, + maxFrequency: + envString({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + path: "auth.sms.max_frequency", + configured: auth.sms.max_frequency, + }) ?? auth.sms.max_frequency, + testOtp: envStringMap({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + path: "auth.sms.test_otp", + configured: auth.sms.test_otp, + }), + provider: resolveSmsProvider({ + sms: auth.sms, + authDocument, + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + }), + }, + externalProviders: resolveExternalProviders({ + external: auth.external, + authDocument, + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + }), + hooks: resolveHooks({ + hooks: auth.hook, + authDocument, + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + }), + }, + } satisfies TranslatedAuthStackConfig; +}); diff --git a/apps/cli/src/next/config/auth-stack-config.unit.test.ts b/apps/cli/src/next/config/auth-stack-config.unit.test.ts new file mode 100644 index 0000000000..a4a6adec09 --- /dev/null +++ b/apps/cli/src/next/config/auth-stack-config.unit.test.ts @@ -0,0 +1,515 @@ +import { type LoadedProjectConfig, ProjectConfigSchema } from "@supabase/config"; +import { Effect, Schema } from "effect"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { translateAuthStackConfig } from "./auth-stack-config.ts"; + +const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +function projectEnvironment(values: Readonly>) { + return { + paths: { + projectRoot: "/project", + supabaseDir: "/project/supabase", + configPath: "/project/supabase/config.toml", + envPath: "/project/supabase/.env", + envLocalPath: "/project/supabase/.env.local", + }, + values, + loadedPaths: [], + sources: {}, + }; +} + +function translateAuth(input: { + readonly configDir: string; + readonly authEnabled: boolean; + readonly projectEnvironment: ReturnType | null; + readonly projectConfig: LoadedProjectConfig["config"]; + readonly rawDocument?: Readonly>; + readonly appliedRemote?: string; + readonly remoteOverridePaths?: ReadonlyArray; +}) { + const { rawDocument, appliedRemote, remoteOverridePaths, ...rest } = input; + const loadedProjectConfig: LoadedProjectConfig | null = + rawDocument === undefined && appliedRemote === undefined + ? null + : { + path: join(input.configDir, "config.toml"), + format: "toml", + config: input.projectConfig, + ignoredPaths: [], + document: rawDocument === undefined ? undefined : { ...rawDocument }, + appliedRemote, + remoteOverridePaths, + }; + return translateAuthStackConfig({ ...rest, loadedProjectConfig }); +} + +describe("translateAuthStackConfig", () => { + it("translates signup, email, SMS, providers, redirects, hooks, and credentials", async () => { + const result = await Effect.runPromise( + translateAuth({ + configDir: "/project/supabase", + authEnabled: true, + projectEnvironment: null, + rawDocument: { + auth: { + email: { smtp: {} }, + sms: { twilio: {} }, + external: { github: {} }, + hook: { custom_access_token: {} }, + }, + }, + projectConfig: decodeProjectConfig({ + auth: { + site_url: "https://app.example.com", + additional_redirect_urls: ["https://app.example.com/callback"], + jwt_expiry: 7200, + jwt_issuer: "https://api.example.com/auth/v1", + enable_signup: false, + jwt_secret: "symmetric-secret-with-at-least-32-characters", + publishable_key: "sb_publishable_override", + secret_key: "sb_secret_override", + email: { + enable_signup: false, + enable_confirmations: true, + smtp: { + enabled: true, + host: "smtp.example.com", + port: 587, + user: "mailer", + pass: "smtp-password", + admin_email: "admin@example.com", + }, + }, + sms: { + enable_signup: true, + twilio: { + enabled: true, + account_sid: "account", + message_service_sid: "service", + auth_token: "sms-token", + }, + }, + external: { + github: { + enabled: true, + client_id: "github-client", + secret: "github-secret", + }, + }, + hook: { + custom_access_token: { + enabled: true, + uri: "pg-functions://postgres/auth/custom-access-token", + secrets: "hook-secret", + }, + }, + }, + }), + }), + ); + + expect(result.credentials).toMatchObject({ + signing: { + _tag: "SymmetricJwtSecret", + secret: "symmetric-secret-with-at-least-32-characters", + }, + publishableKey: "sb_publishable_override", + secretKey: "sb_secret_override", + }); + expect(result.auth).toMatchObject({ + siteUrl: "https://app.example.com", + additionalRedirectUrls: ["https://app.example.com/callback"], + jwtExpiry: 7200, + jwtIssuer: "https://api.example.com/auth/v1", + enableSignup: false, + email: { + enableSignup: false, + enableConfirmations: true, + smtp: { host: "smtp.example.com", pass: "smtp-password" }, + }, + sms: { + enableSignup: true, + provider: { _tag: "twilio", authToken: "sms-token" }, + }, + externalProviders: { + github: { enabled: true, clientId: "github-client", secret: "github-secret" }, + }, + hooks: { + custom_access_token: { enabled: true, secrets: "hook-secret" }, + }, + }); + }); + + it("loads asymmetric signing keys relative to config.toml without exposing them in errors", async () => { + const configDir = await mkdtemp(join(tmpdir(), "auth-stack-config-")); + try { + await writeFile( + join(configDir, "signing-keys.json"), + JSON.stringify([ + { + kty: "EC", + kid: "local-auth-test", + alg: "ES256", + crv: "P-256", + x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", + y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", + d: "dIhR8wywJlqlua4y_yMq2SLhlFXDZJBCvFrY1DCHyVU", + }, + ]), + ); + const result = await Effect.runPromise( + translateAuth({ + configDir, + authEnabled: true, + projectEnvironment: projectEnvironment({ + SUPABASE_AUTH_SIGNING_KEYS_PATH: "signing-keys.json", + }), + projectConfig: decodeProjectConfig({ + auth: { signing_keys_path: "ignored.json" }, + }), + }), + ); + expect(result.credentials.signing).toMatchObject({ + _tag: "AsymmetricJwtKeys", + keys: [expect.objectContaining({ kid: "local-auth-test" })], + }); + + await writeFile( + join(configDir, "signing-keys.json"), + JSON.stringify([ + { + kty: "RSA", + kid: "mismatched-key", + alg: "ES256", + crv: "P-256", + x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", + y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", + d: "private-signing-material", + }, + ]), + ); + const exit = await Effect.runPromise( + translateAuth({ + configDir, + authEnabled: true, + projectEnvironment: null, + projectConfig: decodeProjectConfig({ + auth: { signing_keys_path: "signing-keys.json" }, + }), + }).pipe(Effect.exit), + ); + expect(JSON.stringify(exit)).toContain("auth.signing_keys_path"); + expect(JSON.stringify(exit)).not.toContain("private-signing-material"); + } finally { + await rm(configDir, { recursive: true, force: true }); + } + }); + + it("applies typed Auth environment overrides without retaining secret values", async () => { + const result = await Effect.runPromise( + translateAuth({ + configDir: "/project/supabase", + authEnabled: true, + rawDocument: { auth: { external: { github: {} } } }, + projectEnvironment: projectEnvironment({ + SUPABASE_AUTH_ENABLE_SIGNUP: "false", + SUPABASE_AUTH_JWT_EXPIRY: "7200", + SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS: "https://one.example,https://two.example", + SUPABASE_AUTH_JWT_SECRET: "env(AUTH_SIGNING_SECRET)", + AUTH_SIGNING_SECRET: "environment-jwt-secret-with-32-characters", + SUPABASE_AUTH_EXTERNAL_GITHUB_ENABLED: "true", + SUPABASE_AUTH_EXTERNAL_GITHUB_CLIENT_ID: "environment-client", + SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET: "env(GITHUB_SECRET)", + GITHUB_SECRET: "environment-provider-secret", + }), + projectConfig: decodeProjectConfig({}), + }), + ); + + expect(result.credentials.signing).toEqual({ + _tag: "SymmetricJwtSecret", + secret: "environment-jwt-secret-with-32-characters", + }); + expect(result.auth).toMatchObject({ + enableSignup: false, + jwtExpiry: 7200, + additionalRedirectUrls: ["https://one.example", "https://two.example"], + externalProviders: { + github: { + enabled: true, + clientId: "environment-client", + secret: "environment-provider-secret", + }, + }, + }); + }); + + it("keeps remote Auth credentials and runtime fields above environment bindings", async () => { + const configDir = await mkdtemp(join(tmpdir(), "auth-stack-config-remote-")); + try { + await writeFile( + join(configDir, "remote-signing-keys.json"), + JSON.stringify([ + { + kty: "EC", + kid: "remote-signing-key", + alg: "ES256", + crv: "P-256", + x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", + y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", + d: "dIhR8wywJlqlua4y_yMq2SLhlFXDZJBCvFrY1DCHyVU", + }, + ]), + ); + const projectConfig = decodeProjectConfig({ + auth: { + jwt_secret: "remote-legacy-secret-with-at-least-32-chars", + signing_keys_path: "remote-signing-keys.json", + publishable_key: "remote-publishable", + site_url: "https://remote.example", + additional_redirect_urls: ["https://remote.example/callback"], + enable_signup: false, + email: { + enable_signup: false, + max_frequency: "remote-email-frequency", + smtp: { + enabled: true, + host: "remote.smtp.example", + port: 2525, + user: "remote-user", + pass: "remote-pass", + admin_email: "remote@example.com", + }, + }, + sms: { + enable_signup: false, + template: "remote-template", + test_otp: { "15555550123": "123456" }, + twilio: { + enabled: true, + account_sid: "remote-account", + message_service_sid: "remote-service", + auth_token: "remote-token", + }, + }, + external: { + github: { + enabled: true, + client_id: "remote-client", + secret: "remote-provider-secret", + }, + }, + hook: { + custom_access_token: { + enabled: true, + uri: "pg-functions://postgres/auth/remote-hook", + secrets: "remote-hook-secret", + }, + }, + }, + }); + const remoteOverridePaths = [ + "auth.jwt_secret", + "auth.signing_keys_path", + "auth.publishable_key", + "auth.site_url", + "auth.additional_redirect_urls", + "auth.enable_signup", + "auth.email.enable_signup", + "auth.email.max_frequency", + "auth.email.smtp.enabled", + "auth.email.smtp.host", + "auth.email.smtp.port", + "auth.email.smtp.user", + "auth.email.smtp.pass", + "auth.email.smtp.admin_email", + "auth.sms.enable_signup", + "auth.sms.template", + "auth.sms.test_otp", + "auth.sms.twilio.enabled", + "auth.sms.twilio.account_sid", + "auth.sms.twilio.message_service_sid", + "auth.sms.twilio.auth_token", + "auth.external.github.enabled", + "auth.external.github.client_id", + "auth.external.github.secret", + "auth.hook.custom_access_token.enabled", + "auth.hook.custom_access_token.uri", + "auth.hook.custom_access_token.secrets", + ]; + const result = await Effect.runPromise( + translateAuth({ + configDir, + authEnabled: true, + appliedRemote: "preview", + remoteOverridePaths, + rawDocument: { + auth: { + email: { smtp: { enabled: true } }, + sms: { twilio: {} }, + external: { github: {} }, + hook: { custom_access_token: {} }, + }, + }, + projectEnvironment: projectEnvironment({ + SUPABASE_AUTH_JWT_SECRET: "environment-secret-with-at-least-32-chars", + SUPABASE_AUTH_SIGNING_KEYS_PATH: "missing-environment-keys.json", + SUPABASE_AUTH_PUBLISHABLE_KEY: "environment-publishable", + SUPABASE_AUTH_SITE_URL: "https://environment.example", + SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS: "https://environment.example/callback", + SUPABASE_AUTH_ENABLE_SIGNUP: "true", + SUPABASE_AUTH_EMAIL_ENABLE_SIGNUP: "true", + SUPABASE_AUTH_EMAIL_MAX_FREQUENCY: "environment-email-frequency", + SUPABASE_AUTH_EMAIL_SMTP_ENABLED: "false", + SUPABASE_AUTH_EMAIL_SMTP_HOST: "environment.smtp.example", + SUPABASE_AUTH_EMAIL_SMTP_PORT: "1025", + SUPABASE_AUTH_EMAIL_SMTP_USER: "environment-user", + SUPABASE_AUTH_EMAIL_SMTP_PASS: "environment-pass", + SUPABASE_AUTH_EMAIL_SMTP_ADMIN_EMAIL: "environment@example.com", + SUPABASE_AUTH_SMS_ENABLE_SIGNUP: "true", + SUPABASE_AUTH_SMS_TEMPLATE: "environment-template", + SUPABASE_AUTH_SMS_TEST_OTP: "environment-map-cannot-decode", + SUPABASE_AUTH_SMS_TWILIO_ENABLED: "false", + SUPABASE_AUTH_SMS_TWILIO_ACCOUNT_SID: "environment-account", + SUPABASE_AUTH_SMS_TWILIO_MESSAGE_SERVICE_SID: "environment-service", + SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN: "environment-token", + SUPABASE_AUTH_EXTERNAL_GITHUB_ENABLED: "false", + SUPABASE_AUTH_EXTERNAL_GITHUB_CLIENT_ID: "environment-client", + SUPABASE_AUTH_EXTERNAL_GITHUB_SECRET: "environment-provider-secret", + SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "false", + SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI: + "pg-functions://postgres/auth/environment-hook", + SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS: "environment-hook-secret", + }), + projectConfig, + }), + ); + + expect(result.credentials).toMatchObject({ + signing: { + _tag: "AsymmetricJwtKeys", + keys: [expect.objectContaining({ kid: "remote-signing-key" })], + legacySecret: "remote-legacy-secret-with-at-least-32-chars", + }, + publishableKey: "remote-publishable", + }); + expect(result.auth).toMatchObject({ + siteUrl: "https://remote.example", + additionalRedirectUrls: ["https://remote.example/callback"], + enableSignup: false, + email: { + enableSignup: false, + maxFrequency: "remote-email-frequency", + smtp: { + host: "remote.smtp.example", + port: 2525, + user: "remote-user", + pass: "remote-pass", + adminEmail: "remote@example.com", + }, + }, + sms: { + enableSignup: false, + template: "remote-template", + testOtp: { "15555550123": "123456" }, + provider: { + _tag: "twilio", + accountSid: "remote-account", + messageServiceSid: "remote-service", + authToken: "remote-token", + }, + }, + externalProviders: { + github: { enabled: true, clientId: "remote-client", secret: "remote-provider-secret" }, + }, + hooks: { + custom_access_token: { + enabled: true, + uri: "pg-functions://postgres/auth/remote-hook", + secrets: "remote-hook-secret", + }, + }, + }); + } finally { + await rm(configDir, { recursive: true, force: true }); + } + }); + + it("rejects an environment string for the Auth SMS test OTP map", async () => { + const exit = await Effect.runPromise( + translateAuth({ + configDir: "/project/supabase", + authEnabled: true, + projectEnvironment: projectEnvironment({ + SUPABASE_AUTH_SMS_TEST_OTP: "15555550123:123456", + }), + projectConfig: decodeProjectConfig({}), + }).pipe(Effect.exit), + ); + + expect(JSON.stringify(exit)).toContain("auth.sms.test_otp"); + expect(JSON.stringify(exit)).not.toContain("15555550123:123456"); + }); + + it("reports malformed Auth overrides by path without their values", async () => { + const exit = await Effect.runPromise( + translateAuth({ + configDir: "/project/supabase", + authEnabled: true, + projectEnvironment: projectEnvironment({ + SUPABASE_AUTH_ENABLE_SIGNUP: "private-invalid-boolean", + }), + projectConfig: decodeProjectConfig({}), + }).pipe(Effect.exit), + ); + + expect(JSON.stringify(exit)).toContain("auth.enable_signup"); + expect(JSON.stringify(exit)).not.toContain("private-invalid-boolean"); + }); + + it("applies env-only overrides only for sections registered by the legacy defaults", async () => { + const result = await Effect.runPromise( + translateAuth({ + configDir: "/project/supabase", + authEnabled: true, + projectEnvironment: projectEnvironment({ + // Apple and Twilio are emitted by the legacy default template, so Viper registers them. + SUPABASE_AUTH_EXTERNAL_APPLE_ENABLED: "true", + SUPABASE_AUTH_EXTERNAL_APPLE_CLIENT_ID: "apple-client", + // Hook structs are pointers and remain unregistered until their TOML section exists. + SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "true", + SUPABASE_AUTH_HOOK_CUSTOM_ACCESS_TOKEN_URI: "pg-functions://postgres/auth/hook", + }), + projectConfig: decodeProjectConfig({}), + }), + ); + + expect(result.auth).toMatchObject({ + externalProviders: { apple: { enabled: true, clientId: "apple-client" } }, + hooks: { custom_access_token: { enabled: false } }, + }); + }); + + it("validates signing keys even when Auth is excluded", async () => { + await expect( + Effect.runPromise( + translateAuth({ + configDir: "/missing", + authEnabled: false, + projectEnvironment: null, + projectConfig: decodeProjectConfig({ + auth: { signing_keys_path: "missing.json" }, + }), + }), + ), + ).rejects.toMatchObject({ + _tag: "AuthStackConfigError", + path: "auth.signing_keys_path", + }); + }); +}); diff --git a/apps/cli/src/next/config/core-stack-config.ts b/apps/cli/src/next/config/core-stack-config.ts new file mode 100644 index 0000000000..c6b5eccc73 --- /dev/null +++ b/apps/cli/src/next/config/core-stack-config.ts @@ -0,0 +1,384 @@ +import type { LoadedProjectConfig, ProjectConfig, ProjectEnvironment } from "@supabase/config"; +import type { StackConfig } from "@supabase/stack/effect"; +import { Data } from "effect"; +import { + effectiveEnvironmentOverride, + effectiveString, + effectiveStringList, + parseGoBoolean, + parseGoUint32, +} from "./local-stack-config-values.ts"; + +export const excludedStackServices = [ + "auth", + "edge-runtime", + "postgrest", + "realtime", + "storage", + "imgproxy", + "mailpit", + "pgmeta", + "studio", + "analytics", + "vector", + "pooler", +] as const; + +export type ExcludedStackService = (typeof excludedStackServices)[number]; + +export class LocalStackConfigError extends Data.TaggedError("LocalStackConfigError")<{ + readonly detail: string; + readonly suggestion: string; + readonly paths: ReadonlyArray; +}> {} + +export function invalidLocalStackConfig(path: string, suggestion: string): LocalStackConfigError { + return new LocalStackConfigError({ + detail: `Invalid local stack configuration at ${path}.`, + suggestion, + paths: [path], + }); +} + +function environmentOverride( + path: string, + configured: string | undefined, + environment: ProjectEnvironment | null, + loaded: LoadedProjectConfig | null, +): string | undefined { + return effectiveEnvironmentOverride({ loaded, environment, path }) ?? configured; +} + +function resolveBoolean(input: { + readonly loaded: LoadedProjectConfig | null; + readonly environment: ProjectEnvironment | null; + readonly configured: boolean; + readonly path: string; +}): boolean { + const override = effectiveEnvironmentOverride(input); + if (override === undefined) return input.configured; + const resolved = parseGoBoolean(override); + if (resolved === undefined) { + throw invalidLocalStackConfig( + input.path, + "Use a Go-compatible boolean such as true, false, 1, or 0.", + ); + } + return resolved; +} + +function parseGoPort(value: string): number | undefined { + const signless = value.startsWith("+") ? value.slice(1) : value; + if (signless.length === 0 || signless.startsWith("-")) return undefined; + let base = 10; + let digits = signless; + if (/^0[xX]/.test(signless)) { + base = 16; + digits = signless.slice(2); + } else if (/^0[oO]/.test(signless)) { + base = 8; + digits = signless.slice(2); + } else if (/^0[0-7]+$/.test(signless)) { + base = 8; + digits = signless.slice(1); + } + if (digits.length === 0) return undefined; + const validDigits = base === 16 ? /^[0-9a-fA-F]+$/ : base === 8 ? /^[0-7]+$/ : /^[0-9]+$/; + if (!validDigits.test(digits)) return undefined; + const parsed = Number.parseInt(digits, base); + return Number.isSafeInteger(parsed) && parsed <= 65_535 ? parsed : undefined; +} + +function resolvePort(input: { + readonly loaded: LoadedProjectConfig | null; + readonly environment: ProjectEnvironment | null; + readonly configured: number | undefined; + readonly path: string; + readonly required?: boolean; +}): number | undefined { + const override = effectiveEnvironmentOverride(input); + const resolved = override === undefined ? input.configured : parseGoPort(override); + if (resolved === undefined && input.required !== true && override === undefined) return undefined; + if ( + resolved === undefined || + !Number.isInteger(resolved) || + resolved < 0 || + resolved > 65_535 || + (input.required === true && resolved === 0) + ) { + throw invalidLocalStackConfig(input.path, "Use an integer port between 1 and 65535."); + } + return resolved; +} + +function serviceConfig(base: T | false | undefined, values: T): T { + return { ...(base === false ? {} : base), ...values }; +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function resolveEdgeRuntimePolicy(value: string): "oneshot" | "per_worker" { + if (value === "oneshot" || value === "per_worker") return value; + throw invalidLocalStackConfig("edge_runtime.policy", "Use either oneshot or per_worker."); +} + +function resolveAnalyticsBackend(value: string): "postgres" | "bigquery" { + if (value === "postgres" || value === "bigquery") return value; + throw invalidLocalStackConfig("analytics.backend", "Use either postgres or bigquery."); +} + +function resolvePoolMode(value: string): "transaction" | "session" { + if (value === "transaction" || value === "session") return value; + throw invalidLocalStackConfig("db.pooler.pool_mode", "Use either transaction or session."); +} + +export function resolveCoreStackConfig(input: { + readonly loadedProjectConfig: LoadedProjectConfig | null; + readonly projectConfig: ProjectConfig; + readonly rawDocument?: Readonly>; + readonly projectEnvironment: ProjectEnvironment | null; + readonly exclude: ReadonlyArray; + readonly base: StackConfig; +}): StackConfig { + const { projectConfig, projectEnvironment } = input; + const excluded = new Set(input.exclude); + const enabled = (params: { + readonly configured: boolean; + readonly path: string; + readonly excludedAs: ExcludedStackService; + }) => + resolveBoolean({ + loaded: input.loadedProjectConfig, + environment: projectEnvironment, + configured: params.configured, + path: params.path, + }) && !excluded.has(params.excludedAs); + + const apiEnabled = enabled({ + configured: projectConfig.api.enabled, + path: "api.enabled", + excludedAs: "postgrest", + }); + const authEnabled = enabled({ + configured: projectConfig.auth.enabled, + path: "auth.enabled", + excludedAs: "auth", + }); + const realtimeEnabled = enabled({ + configured: projectConfig.realtime.enabled, + path: "realtime.enabled", + excludedAs: "realtime", + }); + const storageEnabled = enabled({ + configured: projectConfig.storage.enabled, + path: "storage.enabled", + excludedAs: "storage", + }); + const mailpitEnabled = enabled({ + configured: projectConfig.local_smtp.enabled, + path: "local_smtp.enabled", + excludedAs: "mailpit", + }); + const studioEnabled = enabled({ + configured: projectConfig.studio.enabled, + path: "studio.enabled", + excludedAs: "studio", + }); + const analyticsEnabled = enabled({ + configured: projectConfig.analytics.enabled, + path: "analytics.enabled", + excludedAs: "analytics", + }); + const poolerEnabled = enabled({ + configured: projectConfig.db.pooler.enabled, + path: "db.pooler.enabled", + excludedAs: "pooler", + }); + const edgeRuntimeEnabled = enabled({ + configured: projectConfig.edge_runtime.enabled, + path: "edge_runtime.enabled", + excludedAs: "edge-runtime", + }); + const imageTransformationSection = isRecord(input.rawDocument?.storage) + ? input.rawDocument.storage.image_transformation + : undefined; + const imageTransformationEnabled = + isRecord(imageTransformationSection) && + resolveBoolean({ + loaded: input.loadedProjectConfig, + environment: projectEnvironment, + configured: projectConfig.storage.image_transformation?.enabled ?? false, + path: "storage.image_transformation.enabled", + }); + + const apiPort = resolvePort({ + loaded: input.loadedProjectConfig, + environment: projectEnvironment, + configured: projectConfig.api.port, + path: "api.port", + required: apiEnabled, + }); + const dbPort = resolvePort({ + loaded: input.loadedProjectConfig, + environment: projectEnvironment, + configured: projectConfig.db.port, + path: "db.port", + required: true, + }); + const studioPort = resolvePort({ + loaded: input.loadedProjectConfig, + environment: projectEnvironment, + configured: projectConfig.studio.port, + path: "studio.port", + required: studioEnabled, + }); + const mailpitPort = resolvePort({ + loaded: input.loadedProjectConfig, + environment: projectEnvironment, + configured: projectConfig.local_smtp.port, + path: "local_smtp.port", + required: mailpitEnabled, + }); + const mailpitSmtpPort = resolvePort({ + loaded: input.loadedProjectConfig, + environment: projectEnvironment, + configured: projectConfig.local_smtp.smtp_port, + path: "local_smtp.smtp_port", + }); + const mailpitPop3Port = resolvePort({ + loaded: input.loadedProjectConfig, + environment: projectEnvironment, + configured: projectConfig.local_smtp.pop3_port, + path: "local_smtp.pop3_port", + }); + const analyticsPort = resolvePort({ + loaded: input.loadedProjectConfig, + environment: projectEnvironment, + configured: projectConfig.analytics.port, + path: "analytics.port", + required: analyticsEnabled, + }); + const poolerPort = resolvePort({ + loaded: input.loadedProjectConfig, + environment: projectEnvironment, + configured: projectConfig.db.pooler.port, + path: "db.pooler.port", + required: poolerEnabled, + }); + const maxRowsOverride = effectiveEnvironmentOverride({ + loaded: input.loadedProjectConfig, + environment: projectEnvironment, + path: "api.max_rows", + }); + const maxRows = + maxRowsOverride === undefined ? projectConfig.api.max_rows : parseGoUint32(maxRowsOverride); + if (maxRows === undefined) { + throw invalidLocalStackConfig("api.max_rows", "Use a non-negative 32-bit integer."); + } + + return { + ...input.base, + port: apiPort, + postgres: serviceConfig(input.base.postgres, { port: dbPort }), + postgrest: apiEnabled + ? serviceConfig(input.base.postgrest, { + schemas: effectiveStringList({ + loaded: input.loadedProjectConfig, + environment: projectEnvironment, + path: "api.schemas", + configured: projectConfig.api.schemas, + }), + extraSearchPath: effectiveStringList({ + loaded: input.loadedProjectConfig, + environment: projectEnvironment, + path: "api.extra_search_path", + configured: projectConfig.api.extra_search_path, + }), + maxRows, + }) + : false, + auth: authEnabled ? serviceConfig(input.base.auth, {}) : false, + edgeRuntime: edgeRuntimeEnabled + ? serviceConfig(input.base.edgeRuntime, { + policy: resolveEdgeRuntimePolicy( + effectiveString({ + loaded: input.loadedProjectConfig, + environment: projectEnvironment, + path: "edge_runtime.policy", + configured: projectConfig.edge_runtime.policy, + }), + ), + }) + : false, + realtime: realtimeEnabled + ? serviceConfig(input.base.realtime, { + maxHeaderLength: projectConfig.realtime.max_header_length, + }) + : false, + storage: storageEnabled + ? serviceConfig(input.base.storage, { + fileSizeLimit: projectConfig.storage.file_size_limit, + s3ProtocolEnabled: projectConfig.storage.s3_protocol.enabled, + }) + : false, + imgproxy: + storageEnabled && imageTransformationEnabled && !excluded.has("imgproxy") + ? serviceConfig(input.base.imgproxy, {}) + : false, + mailpit: mailpitEnabled + ? serviceConfig(input.base.mailpit, { + port: mailpitPort, + ...(mailpitSmtpPort === undefined || mailpitSmtpPort === 0 + ? {} + : { smtpPort: mailpitSmtpPort }), + ...(mailpitPop3Port === undefined || mailpitPop3Port === 0 + ? {} + : { pop3Port: mailpitPop3Port }), + adminEmail: environmentOverride( + "local_smtp.admin_email", + projectConfig.local_smtp.admin_email, + projectEnvironment, + input.loadedProjectConfig, + ), + senderName: environmentOverride( + "local_smtp.sender_name", + projectConfig.local_smtp.sender_name, + projectEnvironment, + input.loadedProjectConfig, + ), + }) + : false, + pgmeta: studioEnabled && !excluded.has("pgmeta") ? input.base.pgmeta : false, + studio: + studioEnabled && !excluded.has("pgmeta") + ? serviceConfig(input.base.studio, { + port: studioPort, + apiUrl: + environmentOverride( + "studio.api_url", + projectConfig.studio.api_url, + projectEnvironment, + input.loadedProjectConfig, + ) ?? projectConfig.studio.api_url, + }) + : false, + analytics: analyticsEnabled + ? serviceConfig(input.base.analytics, { + port: analyticsPort, + backend: resolveAnalyticsBackend(projectConfig.analytics.backend), + }) + : false, + vector: + analyticsEnabled && !excluded.has("vector") ? serviceConfig(input.base.vector, {}) : false, + pooler: poolerEnabled + ? serviceConfig(input.base.pooler, { + port: poolerPort, + mode: resolvePoolMode(projectConfig.db.pooler.pool_mode), + defaultPoolSize: projectConfig.db.pooler.default_pool_size, + maxClientConn: projectConfig.db.pooler.max_client_conn, + }) + : false, + }; +} diff --git a/apps/cli/src/next/config/data-plane-stack-config-values.ts b/apps/cli/src/next/config/data-plane-stack-config-values.ts new file mode 100644 index 0000000000..9d0bec41fb --- /dev/null +++ b/apps/cli/src/next/config/data-plane-stack-config-values.ts @@ -0,0 +1,97 @@ +import type { LoadedProjectConfig, ProjectEnvironment } from "@supabase/config"; +import { Data } from "effect"; +import { + effectiveEnvironmentOverride, + parseGoBoolean, + parseGoUint32, + resolveEnvironmentReference, +} from "./local-stack-config-values.ts"; + +export class DataPlaneStackConfigError extends Data.TaggedError("DataPlaneStackConfigError")<{ + readonly detail: string; + readonly suggestion: string; + readonly paths: ReadonlyArray; +}> {} + +export function invalidDataPlaneConfig( + path: string, + suggestion: string, +): DataPlaneStackConfigError { + return new DataPlaneStackConfigError({ + detail: `Invalid local stack configuration at ${path}.`, + suggestion, + paths: [path], + }); +} + +export function environmentOverride( + path: string, + configured: string | undefined, + environment: ProjectEnvironment | null, + loaded: LoadedProjectConfig | null, +): string | undefined { + const value = effectiveEnvironmentOverride({ loaded, environment, path }) ?? configured; + return value === undefined ? undefined : resolveEnvironmentReference(value, environment); +} + +/** Mirrors Go's direct os.LookupEnv calls, where a present empty value is significant. */ +export function rawEnvironmentOverride( + name: string, + fallback: string | undefined, + environment: ProjectEnvironment | null, +): string | undefined { + return environment?.values[name] ?? fallback; +} + +export function resolveBooleanOverride(input: { + readonly loaded: LoadedProjectConfig | null; + readonly environment: ProjectEnvironment | null; + readonly configured: boolean; + readonly path: string; +}): boolean { + const override = effectiveEnvironmentOverride(input); + if (override === undefined) return input.configured; + const value = parseGoBoolean(override); + if (value === undefined) { + throw invalidDataPlaneConfig( + input.path, + "Use a Go-compatible boolean such as true, false, 1, or 0.", + ); + } + return value; +} + +export function resolveUintOverride(input: { + readonly loaded: LoadedProjectConfig | null; + readonly environment: ProjectEnvironment | null; + readonly configured: number; + readonly path: string; +}): number { + const override = effectiveEnvironmentOverride(input); + if (override === undefined) return input.configured; + const parsed = parseGoUint32(override); + if (parsed === undefined) { + throw invalidDataPlaneConfig(input.path, "Use a non-negative 32-bit integer."); + } + return parsed; +} + +export function resolveEnumOverride(input: { + readonly loaded: LoadedProjectConfig | null; + readonly environment: ProjectEnvironment | null; + readonly configured: string; + readonly path: string; + readonly values: ReadonlyArray; +}): Value { + const resolved = environmentOverride( + input.path, + input.configured, + input.environment, + input.loaded, + ); + const value = input.values.find((candidate) => candidate === resolved); + if (value === undefined) { + throw invalidDataPlaneConfig(input.path, `Use one of: ${input.values.join(", ")}.`); + } + return value; +} diff --git a/apps/cli/src/next/config/data-plane-stack-config.ts b/apps/cli/src/next/config/data-plane-stack-config.ts new file mode 100644 index 0000000000..32b85d7f32 --- /dev/null +++ b/apps/cli/src/next/config/data-plane-stack-config.ts @@ -0,0 +1,50 @@ +import type { LoadedProjectConfig, ProjectConfig, ProjectEnvironment } from "@supabase/config"; +import type { StackConfig } from "@supabase/stack/effect"; +import { resolveAnalyticsStackConfig } from "./analytics-stack-config.ts"; +import { resolvePoolerStackConfig } from "./pooler-stack-config.ts"; +import { resolveRealtimeStackConfig } from "./realtime-stack-config.ts"; +import { resolveStorageStackConfig } from "./storage-stack-config.ts"; +import { resolveStudioStackConfig } from "./studio-stack-config.ts"; + +export function resolveDataPlaneStackConfig(input: { + readonly loadedProjectConfig: LoadedProjectConfig | null; + readonly projectConfig: ProjectConfig; + readonly projectEnvironment: ProjectEnvironment | null; + readonly configDir: string; + readonly base: StackConfig; +}): StackConfig { + return { + ...input.base, + realtime: resolveRealtimeStackConfig({ + config: input.projectConfig.realtime, + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + base: input.base.realtime, + }), + storage: resolveStorageStackConfig({ + config: input.projectConfig.storage, + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + base: input.base.storage, + }), + analytics: resolveAnalyticsStackConfig({ + config: input.projectConfig.analytics, + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + configDir: input.configDir, + base: input.base.analytics, + }), + studio: resolveStudioStackConfig({ + config: input.projectConfig.studio, + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + base: input.base.studio, + }), + pooler: resolvePoolerStackConfig({ + config: input.projectConfig.db.pooler, + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + base: input.base.pooler, + }), + }; +} diff --git a/apps/cli/src/next/config/data-plane-stack-config.unit.test.ts b/apps/cli/src/next/config/data-plane-stack-config.unit.test.ts new file mode 100644 index 0000000000..435b93c3c5 --- /dev/null +++ b/apps/cli/src/next/config/data-plane-stack-config.unit.test.ts @@ -0,0 +1,251 @@ +import { + ProjectConfigSchema, + type LoadedProjectConfig, + type ProjectEnvironment, +} from "@supabase/config"; +import { Schema } from "effect"; +import { describe, expect, it } from "vitest"; +import { resolveDataPlaneStackConfig } from "./data-plane-stack-config.ts"; + +const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +function environment(values: Readonly>): ProjectEnvironment { + return { + paths: { + projectRoot: "/project", + supabaseDir: "/project/supabase", + configPath: "/project/supabase/config.toml", + envPath: "/project/supabase/.env", + envLocalPath: "/project/supabase/.env.local", + }, + values, + loadedPaths: [], + sources: {}, + }; +} + +describe("resolveDataPlaneStackConfig", () => { + it("translates project values and legacy environment overrides", () => { + const projectConfig = decodeProjectConfig({ + realtime: { ip_version: "IPv4", max_header_length: 4096 }, + storage: { + file_size_limit: "50MiB", + s3_protocol: { enabled: true }, + vector: { enabled: true }, + }, + analytics: { + enabled: true, + backend: "bigquery", + gcp_project_id: "config-project", + gcp_project_number: "123", + gcp_jwt_path: "credentials.json", + }, + studio: { openai_api_key: "env(OPENAI_API_KEY)" }, + db: { + pooler: { + pool_mode: "transaction", + default_pool_size: 20, + max_client_conn: 100, + }, + }, + }); + + const resolved = resolveDataPlaneStackConfig({ + loadedProjectConfig: null, + projectConfig, + projectEnvironment: environment({ + SUPABASE_REALTIME_IP_VERSION: "IPv6", + SUPABASE_REALTIME_MAX_HEADER_LENGTH: "0x2000", + SUPABASE_STORAGE_FILE_SIZE_LIMIT: "5MiB", + SUPABASE_STORAGE_S3_PROTOCOL_ENABLED: "false", + SUPABASE_STORAGE_VECTOR_ENABLED: "true", + VECTOR_BUCKET_PROVIDER: "custom-provider", + VECTOR_STORE_MIGRATIONS_ENABLED: "", + VECTOR_DATABASE_URL: "postgresql://vector-secret", + SUPABASE_ANALYTICS_GCP_PROJECT_ID: "environment-project", + OPENAI_API_KEY: "openai-secret", + SUPABASE_DB_POOLER_POOL_MODE: "session", + SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE: "0x20", + SUPABASE_DB_POOLER_MAX_CLIENT_CONN: "0200", + }), + configDir: "/project/supabase", + base: { realtime: {}, storage: {}, analytics: {}, studio: {}, pooler: {} }, + }); + + expect(resolved.realtime).toMatchObject({ ipVersion: "IPv6", maxHeaderLength: 8192 }); + expect(resolved.storage).toMatchObject({ + fileSizeLimit: "5242880", + s3ProtocolEnabled: false, + vectorRuntime: { + enabled: "true", + provider: "custom-provider", + migrationsEnabled: "", + databaseUrl: "postgresql://vector-secret", + }, + }); + expect(resolved.analytics).toMatchObject({ + backend: "bigquery", + gcp: { + projectId: "environment-project", + projectNumber: "123", + credentialsPath: "/project/supabase/credentials.json", + }, + }); + expect(resolved.studio).toMatchObject({ openAiApiKey: "openai-secret" }); + expect(resolved.pooler).toMatchObject({ + mode: "session", + defaultPoolSize: 32, + maxClientConn: 128, + }); + }); + + it("preserves exclusions while still validating environment overrides", () => { + const projectConfig = decodeProjectConfig({ analytics: { enabled: false } }); + const resolved = resolveDataPlaneStackConfig({ + loadedProjectConfig: null, + projectConfig, + projectEnvironment: null, + configDir: "/project/supabase", + base: { realtime: false, storage: false, analytics: false, studio: false, pooler: false }, + }); + expect(resolved).toMatchObject({ + realtime: false, + storage: false, + analytics: false, + studio: false, + pooler: false, + }); + + const privateValue = "private-invalid-transport"; + expect(() => + resolveDataPlaneStackConfig({ + loadedProjectConfig: null, + projectConfig, + projectEnvironment: environment({ SUPABASE_REALTIME_IP_VERSION: privateValue }), + configDir: "/project/supabase", + base: { realtime: false }, + }), + ).toThrowError(expect.objectContaining({ paths: ["realtime.ip_version"] })); + try { + resolveDataPlaneStackConfig({ + loadedProjectConfig: null, + projectConfig, + projectEnvironment: environment({ SUPABASE_REALTIME_IP_VERSION: privateValue }), + configDir: "/project/supabase", + base: { realtime: false }, + }); + } catch (error) { + expect(JSON.stringify(error)).not.toContain(privateValue); + } + }); + + it("keeps selected remote values ahead of legacy environment bindings", () => { + const document = { + realtime: { ip_version: "IPv6", max_header_length: 8192 }, + storage: { file_size_limit: "5MiB", s3_protocol: { enabled: false } }, + analytics: { + enabled: true, + backend: "bigquery", + gcp_project_id: "remote-project", + gcp_project_number: "123", + gcp_jwt_path: "remote.json", + }, + studio: { openai_api_key: "remote-openai" }, + db: { pooler: { pool_mode: "session", default_pool_size: 32, max_client_conn: 128 } }, + }; + const projectConfig = decodeProjectConfig(document); + const remoteOverridePaths = [ + "realtime.ip_version", + "realtime.max_header_length", + "storage.file_size_limit", + "storage.s3_protocol.enabled", + "analytics.enabled", + "analytics.backend", + "analytics.gcp_project_id", + "analytics.gcp_project_number", + "analytics.gcp_jwt_path", + "studio.openai_api_key", + "db.pooler.pool_mode", + "db.pooler.default_pool_size", + "db.pooler.max_client_conn", + ]; + const loaded: LoadedProjectConfig = { + path: "/project/supabase/config.toml", + format: "toml", + config: projectConfig, + document, + appliedRemote: "preview", + remoteOverridePaths, + ignoredPaths: [], + }; + + const resolved = resolveDataPlaneStackConfig({ + loadedProjectConfig: loaded, + projectConfig, + projectEnvironment: environment({ + SUPABASE_REALTIME_IP_VERSION: "invalid-private-value", + SUPABASE_REALTIME_MAX_HEADER_LENGTH: "invalid-private-value", + SUPABASE_STORAGE_FILE_SIZE_LIMIT: "invalid-private-value", + SUPABASE_STORAGE_S3_PROTOCOL_ENABLED: "invalid-private-value", + SUPABASE_ANALYTICS_ENABLED: "false", + SUPABASE_ANALYTICS_BACKEND: "postgres", + SUPABASE_ANALYTICS_GCP_PROJECT_ID: "environment-project", + SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER: "999", + SUPABASE_ANALYTICS_GCP_JWT_PATH: "environment.json", + SUPABASE_STUDIO_OPENAI_API_KEY: "environment-openai", + SUPABASE_DB_POOLER_POOL_MODE: "invalid-private-value", + SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE: "invalid-private-value", + SUPABASE_DB_POOLER_MAX_CLIENT_CONN: "invalid-private-value", + }), + configDir: "/project/supabase", + base: { realtime: {}, storage: {}, analytics: {}, studio: {}, pooler: {} }, + }); + + expect(resolved).toMatchObject({ + realtime: { ipVersion: "IPv6", maxHeaderLength: 8192 }, + storage: { fileSizeLimit: "5242880", s3ProtocolEnabled: false }, + analytics: { + backend: "bigquery", + gcp: { + projectId: "remote-project", + projectNumber: "123", + credentialsPath: "/project/supabase/remote.json", + }, + }, + studio: { openAiApiKey: "remote-openai" }, + pooler: { mode: "session", defaultPoolSize: 32, maxClientConn: 128 }, + }); + }); + + it("reports invalid sizes and missing BigQuery fields by path only", () => { + const invalidSize = "private-invalid-size"; + const projectConfig = decodeProjectConfig({ + analytics: { enabled: true, backend: "bigquery" }, + }); + const scenarios: ReadonlyArray<{ + readonly values: Readonly>; + readonly path: string; + }> = [ + { + values: { SUPABASE_STORAGE_FILE_SIZE_LIMIT: invalidSize }, + path: "storage.file_size_limit", + }, + { values: {}, path: "analytics.gcp_project_id" }, + ]; + for (const scenario of scenarios) { + try { + resolveDataPlaneStackConfig({ + loadedProjectConfig: null, + projectConfig, + projectEnvironment: environment(scenario.values), + configDir: "/project/supabase", + base: { storage: {}, analytics: {} }, + }); + throw new Error("expected translator failure"); + } catch (error) { + expect(error).toEqual(expect.objectContaining({ paths: [scenario.path] })); + expect(JSON.stringify(error)).not.toContain(invalidSize); + } + } + }); +}); diff --git a/apps/cli/src/next/config/database-bootstrap-config.ts b/apps/cli/src/next/config/database-bootstrap-config.ts new file mode 100644 index 0000000000..6c0574361a --- /dev/null +++ b/apps/cli/src/next/config/database-bootstrap-config.ts @@ -0,0 +1,318 @@ +import type { LoadedProjectConfig, ProjectEnvironment } from "@supabase/config"; +import type { DatabaseBootstrapConfig, DatabaseSeedFile } from "@supabase/stack/effect"; +import { Effect } from "effect"; +import { createHash } from "node:crypto"; +import { glob, readdir, readFile, stat } from "node:fs/promises"; +import { dirname, isAbsolute, join, parse, relative, sep } from "node:path"; +import { invalidLocalStackConfig, LocalStackConfigError } from "./core-stack-config.ts"; + +const GO_BOOLEAN_VALUES: Readonly> = { + "1": true, + t: true, + T: true, + TRUE: true, + true: true, + True: true, + "0": false, + f: false, + F: false, + FALSE: false, + false: false, + False: false, +}; + +const migrationFilePattern = /^([0-9]+)_(.*)\.sql$/; + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nestedValue( + root: Readonly> | undefined, + path: ReadonlyArray, +): unknown { + let current: unknown = root; + for (const segment of path) { + if (!isRecord(current)) return undefined; + current = current[segment]; + } + return current; +} + +function remoteDefines(loaded: LoadedProjectConfig, path: ReadonlyArray): boolean { + if (loaded.appliedRemote === undefined || loaded.document === undefined) return false; + const remotes = nestedValue(loaded.document, ["remotes"]); + if (!isRecord(remotes)) return false; + const remote = remotes[loaded.appliedRemote]; + return isRecord(remote) && nestedValue(remote, path) !== undefined; +} + +function environmentOverride( + name: string, + environment: ProjectEnvironment | null, +): string | undefined { + const value = environment?.values[name]; + if (value === undefined || value.length === 0) return undefined; + const match = /^env\(([^)]+)\)$/.exec(value); + if (match === null) return value; + const referencedName = match[1]; + if (referencedName === undefined) return value; + const referenced = environment?.values[referencedName]; + return referenced === undefined || referenced.length === 0 ? value : referenced; +} + +function resolveBoolean(input: { + readonly loaded: LoadedProjectConfig; + readonly environment: ProjectEnvironment | null; + readonly path: ReadonlyArray; + readonly envName: string; + readonly configured: boolean; +}): boolean { + const override = remoteDefines(input.loaded, input.path) + ? undefined + : environmentOverride(input.envName, input.environment); + if (override === undefined) return input.configured; + const resolved = GO_BOOLEAN_VALUES[override]; + if (resolved === undefined) { + throw invalidLocalStackConfig( + input.path.join("."), + "Use a Go-compatible boolean such as true, false, 1, or 0.", + ); + } + return resolved; +} + +function resolveList(input: { + readonly loaded: LoadedProjectConfig; + readonly environment: ProjectEnvironment | null; + readonly path: ReadonlyArray; + readonly envName: string; + readonly configured: ReadonlyArray; +}): ReadonlyArray { + const override = remoteDefines(input.loaded, input.path) + ? undefined + : environmentOverride(input.envName, input.environment); + return override === undefined ? input.configured : override.split(","); +} + +async function exists(path: string): Promise { + try { + await stat(path); + return true; + } catch (cause) { + if (isRecord(cause) && cause.code === "ENOENT") return false; + throw cause; + } +} + +async function sqlFilesInDirectory(path: string): Promise> { + const entries = await readdir(path, { withFileTypes: true }); + const files: string[] = []; + for (const entry of entries.sort((left, right) => + left.name < right.name ? -1 : left.name > right.name ? 1 : 0, + )) { + const entryPath = join(path, entry.name); + if (entry.isDirectory()) { + files.push(...(await sqlFilesInDirectory(entryPath))); + } else if (entry.isFile() && entry.name.endsWith(".sql")) { + files.push(entryPath); + } + } + return files; +} + +async function expandSqlPatterns(input: { + readonly patterns: ReadonlyArray; + readonly configDir: string; +}): Promise<{ readonly files: ReadonlyArray; readonly hasUnmatchedPattern: boolean }> { + const files: string[] = []; + const seen = new Set(); + let hasUnmatchedPattern = false; + + for (const pattern of input.patterns) { + const absolutePattern = isAbsolute(pattern) ? pattern : join(input.configDir, pattern); + let matches: ReadonlyArray; + try { + matches = /[*?[]/.test(absolutePattern) + ? await (async () => { + const root = parse(absolutePattern).root; + const patternFromRoot = relative(root, absolutePattern).split(sep).join("/"); + return (await Array.fromAsync(glob(patternFromRoot, { cwd: root }))) + .map((match) => (isAbsolute(match) ? match : join(root, match))) + .sort(); + })() + : (await exists(absolutePattern)) + ? [absolutePattern] + : []; + } catch (cause) { + if (isRecord(cause) && cause.code !== undefined && cause.code !== "EINVAL") throw cause; + hasUnmatchedPattern = true; + continue; + } + if (matches.length === 0) { + hasUnmatchedPattern = true; + continue; + } + + let patternMatchedSql = false; + for (const match of matches) { + const info = await stat(match); + const expanded = info.isDirectory() + ? await sqlFilesInDirectory(match) + : match.endsWith(".sql") + ? [match] + : []; + if (expanded.length > 0) patternMatchedSql = true; + for (const file of expanded) { + if (!seen.has(file)) { + seen.add(file); + files.push(file); + } + } + } + if (!patternMatchedSql) hasUnmatchedPattern = true; + } + + return { files, hasUnmatchedPattern }; +} + +async function conventionalMigrationFiles(configDir: string): Promise> { + const migrationsDir = join(configDir, "migrations"); + if (!(await exists(migrationsDir))) return []; + const entries = await readdir(migrationsDir, { withFileTypes: true }); + return entries + .filter((entry) => entry.isFile() && migrationFilePattern.test(entry.name)) + .map((entry) => join(migrationsDir, entry.name)) + .sort(); +} + +function portableProjectPath(projectRoot: string, file: string): string { + const projectRelative = relative(projectRoot, file); + if ( + projectRelative === "" || + projectRelative === ".." || + projectRelative.startsWith(`..${sep}`) + ) { + return file.split(sep).join("/"); + } + return projectRelative.split(sep).join("/"); +} + +async function seedFile(projectRoot: string, path: string): Promise { + const contents = await readFile(path); + return { + path, + historyPath: portableProjectPath(projectRoot, path), + checksum: createHash("sha256").update(contents).digest("hex"), + }; +} + +export interface DatabaseBootstrapTranslation { + readonly config: DatabaseBootstrapConfig | undefined; + readonly warnings: ReadonlyArray<{ + readonly paths: ReadonlyArray; + readonly message: string; + }>; +} + +export const translateDatabaseBootstrapConfig = Effect.fnUntraced(function* (input: { + readonly loadedProjectConfig: LoadedProjectConfig | null; + readonly projectEnvironment: ProjectEnvironment | null; + readonly projectRoot: string; +}) { + if (input.loadedProjectConfig === null) { + return { config: undefined, warnings: [] }; + } + + const loaded = input.loadedProjectConfig; + const configDir = dirname(loaded.path); + + return yield* Effect.tryPromise({ + try: async (): Promise => { + const schemaPaths = resolveList({ + loaded, + environment: input.projectEnvironment, + path: ["db", "migrations", "schema_paths"], + envName: "SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS", + configured: loaded.config.db.migrations.schema_paths, + }); + if (schemaPaths.length > 0) { + throw invalidLocalStackConfig( + "db.migrations.schema_paths", + "Use the legacy local stack until declarative schema diffing is implemented.", + ); + } + + const migrationsEnabled = resolveBoolean({ + loaded, + environment: input.projectEnvironment, + path: ["db", "migrations", "enabled"], + envName: "SUPABASE_DB_MIGRATIONS_ENABLED", + configured: loaded.config.db.migrations.enabled, + }); + const seedEnabled = resolveBoolean({ + loaded, + environment: input.projectEnvironment, + path: ["db", "seed", "enabled"], + envName: "SUPABASE_DB_SEED_ENABLED", + configured: loaded.config.db.seed.enabled, + }); + + const seedPatterns = resolveList({ + loaded, + environment: input.projectEnvironment, + path: ["db", "seed", "sql_paths"], + envName: "SUPABASE_DB_SEED_SQL_PATHS", + configured: loaded.config.db.seed.sql_paths, + }); + + let migrationFiles: ReadonlyArray = []; + try { + migrationFiles = migrationsEnabled ? await conventionalMigrationFiles(configDir) : []; + } catch { + throw invalidLocalStackConfig( + "db.migrations", + "Ensure the migrations directory is readable, or use the legacy local stack.", + ); + } + if (migrationFiles.length > 0) { + throw invalidLocalStackConfig( + "db.migrations.enabled", + "Use the legacy local stack until migration execution preserves transaction boundaries and statement history.", + ); + } + const resolvedSeeds = + seedEnabled && seedPatterns.length > 0 + ? await expandSqlPatterns({ + patterns: seedPatterns, + configDir, + }) + : { files: [], hasUnmatchedPattern: false }; + const seedFiles = await Promise.all( + resolvedSeeds.files.map((path) => seedFile(input.projectRoot, path)), + ); + + const config = seedFiles.length === 0 ? undefined : { seedFiles }; + return { + config, + warnings: resolvedSeeds.hasUnmatchedPattern + ? [ + { + paths: ["db.seed.sql_paths"], + message: + "Some configured db.seed.sql_paths patterns matched no SQL files and were skipped.", + }, + ] + : [], + }; + }, + catch: (cause) => + cause instanceof LocalStackConfigError + ? cause + : new LocalStackConfigError({ + detail: "Invalid local stack configuration at db.seed.sql_paths.", + suggestion: "Ensure configured seed paths are readable.", + paths: ["db.seed.sql_paths"], + }), + }); +}); diff --git a/apps/cli/src/next/config/database-bootstrap-config.unit.test.ts b/apps/cli/src/next/config/database-bootstrap-config.unit.test.ts new file mode 100644 index 0000000000..f4c759c1c7 --- /dev/null +++ b/apps/cli/src/next/config/database-bootstrap-config.unit.test.ts @@ -0,0 +1,283 @@ +import { + ProjectConfigSchema, + type LoadedProjectConfig, + type ProjectEnvironment, +} from "@supabase/config"; +import { Effect, Schema } from "effect"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { translateDatabaseBootstrapConfig } from "./database-bootstrap-config.ts"; + +const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +function loaded( + projectRoot: string, + document: Record, + options: { readonly appliedRemote?: string } = {}, +): LoadedProjectConfig { + return { + path: join(projectRoot, "supabase", "config.toml"), + format: "toml", + config: decodeProjectConfig(document), + document, + appliedRemote: options.appliedRemote, + ignoredPaths: [], + }; +} + +function environment( + projectRoot: string, + values: Readonly>, +): ProjectEnvironment { + return { + paths: { + projectRoot, + supabaseDir: join(projectRoot, "supabase"), + configPath: join(projectRoot, "supabase", "config.toml"), + envPath: join(projectRoot, "supabase", ".env"), + envLocalPath: join(projectRoot, "supabase", ".env.local"), + }, + values, + loadedPaths: [], + sources: {}, + }; +} + +describe("translateDatabaseBootstrapConfig", () => { + it("resolves ordered, deduplicated seed inputs", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "stack-database-bootstrap-")); + const supabaseDir = join(projectRoot, "supabase"); + try { + await mkdir(join(supabaseDir, "seeds", "nested"), { recursive: true }); + await writeFile(join(supabaseDir, "seeds", "a.sql"), "insert into a values (1);"); + await writeFile(join(supabaseDir, "seeds", "nested", "b.sql"), "insert into b values (2);"); + + const result = await Effect.runPromise( + translateDatabaseBootstrapConfig({ + loadedProjectConfig: loaded(projectRoot, { + db: { + migrations: { enabled: false }, + seed: { enabled: true, sql_paths: ["./seeds", "./seeds/a.sql"] }, + }, + }), + projectEnvironment: null, + projectRoot, + }), + ); + + expect(result.config?.seedFiles?.map(({ historyPath }) => historyPath)).toEqual([ + "supabase/seeds/a.sql", + "supabase/seeds/nested/b.sql", + ]); + expect( + result.config?.seedFiles?.every(({ checksum }) => /^[0-9a-f]{64}$/.test(checksum)), + ).toBe(true); + expect(result.warnings).toEqual([]); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("blocks conventional migrations until the stack executor preserves legacy semantics", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "stack-database-migrations-")); + const supabaseDir = join(projectRoot, "supabase"); + try { + await mkdir(join(supabaseDir, "migrations"), { recursive: true }); + await writeFile(join(supabaseDir, "migrations", "1_private-migration.sql"), "VACUUM;"); + + const exit = await Effect.runPromise( + translateDatabaseBootstrapConfig({ + loadedProjectConfig: loaded(projectRoot, { db: { seed: { enabled: false } } }), + projectEnvironment: null, + projectRoot, + }).pipe(Effect.exit), + ); + + expect(JSON.stringify(exit)).toContain("db.migrations.enabled"); + expect(JSON.stringify(exit)).not.toContain("private-migration.sql"); + expect(JSON.stringify(exit)).not.toContain("VACUUM"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("allows migration discovery to be explicitly disabled", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "stack-database-migrations-disabled-")); + const supabaseDir = join(projectRoot, "supabase"); + try { + await mkdir(join(supabaseDir, "migrations"), { recursive: true }); + await writeFile(join(supabaseDir, "migrations", "1_existing.sql"), "select 1;"); + + const result = await Effect.runPromise( + translateDatabaseBootstrapConfig({ + loadedProjectConfig: loaded(projectRoot, { + db: { migrations: { enabled: false }, seed: { enabled: false } }, + }), + projectEnvironment: null, + projectRoot, + }), + ); + + expect(result).toEqual({ config: undefined, warnings: [] }); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("allows migrations to remain enabled when no conventional files exist", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "stack-database-migrations-empty-")); + try { + await mkdir(join(projectRoot, "supabase", "migrations"), { recursive: true }); + + const result = await Effect.runPromise( + translateDatabaseBootstrapConfig({ + loadedProjectConfig: loaded(projectRoot, { + db: { migrations: { enabled: true }, seed: { enabled: false } }, + }), + projectEnvironment: null, + projectRoot, + }), + ); + + expect(result).toEqual({ config: undefined, warnings: [] }); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("attributes migration discovery failures to db.migrations only", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "stack-database-migration-errors-")); + const supabaseDir = join(projectRoot, "supabase"); + try { + await mkdir(supabaseDir, { recursive: true }); + await writeFile(join(supabaseDir, "migrations"), "not a directory"); + + const exit = await Effect.runPromise( + translateDatabaseBootstrapConfig({ + loadedProjectConfig: loaded(projectRoot, { db: { seed: { enabled: false } } }), + projectEnvironment: null, + projectRoot, + }).pipe(Effect.exit), + ); + + expect(JSON.stringify(exit)).toContain("db.migrations"); + expect(JSON.stringify(exit)).not.toContain("db.seed.sql_paths"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("rejects declarative schema paths without exposing their values", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "stack-database-schema-")); + const supabaseDir = join(projectRoot, "supabase"); + try { + await mkdir(join(supabaseDir, "migrations"), { recursive: true }); + await mkdir(join(supabaseDir, "schemas"), { recursive: true }); + await writeFile(join(supabaseDir, "migrations", "20240101000000_ignored.sql"), "select 0;"); + await writeFile(join(supabaseDir, "schemas", "first.sql"), "select 1;"); + await writeFile(join(supabaseDir, "schemas", "second.sql"), "select 2;"); + + const exit = await Effect.runPromise( + translateDatabaseBootstrapConfig({ + loadedProjectConfig: loaded(projectRoot, { + db: { + migrations: { + enabled: true, + schema_paths: ["./schemas/second.sql", "./schemas/first.sql"], + }, + seed: { enabled: false }, + }, + }), + projectEnvironment: null, + projectRoot, + }).pipe(Effect.exit), + ); + + expect(JSON.stringify(exit)).toContain("db.migrations.schema_paths"); + expect(JSON.stringify(exit)).not.toContain("second.sql"); + expect(JSON.stringify(exit)).not.toContain("first.sql"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("applies Go-compatible env overrides while preserving remote precedence", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "stack-database-env-")); + const supabaseDir = join(projectRoot, "supabase"); + try { + await mkdir(join(supabaseDir, "migrations"), { recursive: true }); + await writeFile(join(supabaseDir, "migrations", "20240101000000_remote.sql"), "select 1;"); + const document = { + db: { + migrations: { enabled: false }, + seed: { enabled: false }, + }, + remotes: { + staging: { + project_id: "abcdefghijklmnopqrst", + db: { migrations: { enabled: false }, seed: { enabled: false } }, + }, + }, + }; + + const result = await Effect.runPromise( + translateDatabaseBootstrapConfig({ + loadedProjectConfig: loaded(projectRoot, document, { appliedRemote: "staging" }), + projectEnvironment: environment(projectRoot, { + SUPABASE_DB_MIGRATIONS_ENABLED: "false", + SUPABASE_DB_SEED_ENABLED: "true", + }), + projectRoot, + }), + ); + + expect(result.config).toBeUndefined(); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("reports malformed overrides by config path only and warns on unmatched globs", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "stack-database-errors-")); + try { + await mkdir(join(projectRoot, "supabase"), { recursive: true }); + const malformed = await Effect.runPromise( + translateDatabaseBootstrapConfig({ + loadedProjectConfig: loaded(projectRoot, {}), + projectEnvironment: environment(projectRoot, { + SUPABASE_DB_SEED_ENABLED: "private-invalid-boolean", + }), + projectRoot, + }).pipe(Effect.exit), + ); + const unmatched = await Effect.runPromise( + translateDatabaseBootstrapConfig({ + loadedProjectConfig: loaded(projectRoot, { + db: { + migrations: { enabled: false }, + seed: { enabled: true, sql_paths: ["./private-missing-seed.sql"] }, + }, + }), + projectEnvironment: null, + projectRoot, + }), + ); + + expect(JSON.stringify(malformed)).toContain("db.seed.enabled"); + expect(JSON.stringify(malformed)).not.toContain("private-invalid-boolean"); + expect(unmatched.config).toBeUndefined(); + expect(unmatched.warnings).toEqual([ + { + paths: ["db.seed.sql_paths"], + message: + "Some configured db.seed.sql_paths patterns matched no SQL files and were skipped.", + }, + ]); + expect(JSON.stringify(unmatched.warnings)).not.toContain("private-missing-seed.sql"); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/next/config/functions-stack-config.ts b/apps/cli/src/next/config/functions-stack-config.ts new file mode 100644 index 0000000000..10840e299e --- /dev/null +++ b/apps/cli/src/next/config/functions-stack-config.ts @@ -0,0 +1,120 @@ +import { + inferFunctionsManifest, + loadDotEnvFile, + ProjectConfigSchema, + resolveProjectSubtree, + type FunctionsManifest, + type LoadedProjectConfig, + type ProjectConfig, + type ProjectEnvironment, +} from "@supabase/config"; +import type { ResolvedFunctionsBundle } from "@supabase/stack/effect"; +import { Effect, Redacted, Schema } from "effect"; +import { resolve } from "node:path"; + +const decodeDefaultProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); +const defaultProjectConfig = decodeDefaultProjectConfig({}); + +interface ProjectFunctionsInput { + readonly loadedProjectConfig: LoadedProjectConfig | null; + readonly projectEnvironment: Pick | null; + readonly projectRoot: string; + readonly configDir: string; + readonly envFilePath: string; +} + +export interface FunctionsDevStackConfigInput extends ProjectFunctionsInput { + readonly noVerifyJwt: boolean; +} + +export type StartFunctionsStackConfigInput = ProjectFunctionsInput; + +function reveal(value: string | Redacted.Redacted): string { + return Redacted.isRedacted(value) ? Redacted.value(value) : value; +} + +function absoluteConfigPath(configDir: string, path: string): string { + return resolve(configDir, path.startsWith("./") ? path.slice(2) : path); +} + +const resolveProjectFunctions = Effect.fnUntraced(function* (input: ProjectFunctionsInput) { + const projectConfig = input.loadedProjectConfig?.config ?? defaultProjectConfig; + const environment = input.projectEnvironment ?? { values: {} }; + const resolved = yield* resolveProjectSubtree(projectConfig.functions, environment, "functions"); + const functions: ProjectConfig["functions"] = Object.fromEntries( + Object.entries(resolved).map(([name, config]) => [ + name, + { + ...config, + entrypoint: reveal(config.entrypoint), + import_map: reveal(config.import_map), + static_files: config.static_files.map(reveal), + env: Object.fromEntries( + Object.entries(config.env).map(([key, value]) => [key, reveal(value)]), + ), + }, + ]), + ); + const manifest = yield* inferFunctionsManifest({ + cwd: input.projectRoot, + config: { ...projectConfig, functions }, + }); + + return { manifest, projectConfig, environment }; +}); + +const makeFunctionsBundle = Effect.fnUntraced(function* ( + input: ProjectFunctionsInput, + manifest: FunctionsManifest, + sharedEnv: Readonly>, + noVerifyJwt: boolean, +) { + const env = { ...sharedEnv, ...(yield* loadDotEnvFile(input.envFilePath)) }; + + return { + env, + functions: Object.entries(manifest) + .filter(([, config]) => config.enabled) + .map(([name, config]) => ({ + name, + verifyJWT: noVerifyJwt ? false : config.verify_jwt, + entrypointPath: absoluteConfigPath(input.configDir, config.entrypoint), + importMapPath: + config.import_map === "" ? null : absoluteConfigPath(input.configDir, config.import_map), + staticFiles: config.static_files.map((path) => absoluteConfigPath(input.configDir, path)), + env: config.env, + })), + } satisfies ResolvedFunctionsBundle; +}); + +/** Resolve the standalone functions-dev bundle without adding project Edge Runtime secrets. */ +export const translateFunctionsDevStackConfig = Effect.fnUntraced(function* ( + input: FunctionsDevStackConfigInput, +) { + const { manifest } = yield* resolveProjectFunctions(input); + return yield* makeFunctionsBundle(input, manifest, {}, input.noVerifyJwt); +}); + +/** + * Resolve the ordinary start bundle. Project Edge Runtime secrets form the + * lowest-precedence shared environment; `functions/.env` overrides them. + */ +export const translateStartFunctionsStackConfig = Effect.fnUntraced(function* ( + input: StartFunctionsStackConfigInput, +) { + const { manifest, projectConfig, environment } = yield* resolveProjectFunctions(input); + const edgeRuntime = yield* resolveProjectSubtree( + projectConfig.edge_runtime, + environment, + "edge_runtime", + ); + const edgeRuntimeSecrets = Object.fromEntries( + Object.entries(edgeRuntime.secrets ?? {}).flatMap(([name, value]) => + Redacted.isRedacted(value) && Redacted.value(value).length > 0 + ? [[name.toUpperCase(), Redacted.value(value)] as const] + : [], + ), + ); + + return yield* makeFunctionsBundle(input, manifest, edgeRuntimeSecrets, false); +}); diff --git a/apps/cli/src/next/config/functions-stack-config.unit.test.ts b/apps/cli/src/next/config/functions-stack-config.unit.test.ts new file mode 100644 index 0000000000..c57b113584 --- /dev/null +++ b/apps/cli/src/next/config/functions-stack-config.unit.test.ts @@ -0,0 +1,150 @@ +import { BunServices } from "@effect/platform-bun"; +import { loadProjectConfig, loadProjectEnvironment } from "@supabase/config"; +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit } from "effect"; +import { mkdtempSync } from "node:fs"; +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { translateStartFunctionsStackConfig } from "./functions-stack-config.ts"; + +function makeProject() { + return mkdtempSync(join(tmpdir(), "supabase-functions-stack-config-")); +} + +describe("translateStartFunctionsStackConfig", () => { + it.live("resolves manifest paths and exact environment precedence before stack handoff", () => { + const projectRoot = makeProject(); + + return Effect.gen(function* () { + const supabaseDir = join(projectRoot, "supabase"); + yield* Effect.promise(() => + Promise.all([ + mkdir(join(supabaseDir, "functions", "auto"), { recursive: true }), + mkdir(join(supabaseDir, "functions", "hello", "assets"), { recursive: true }), + mkdir(join(supabaseDir, "functions", "disabled"), { recursive: true }), + ]), + ); + yield* Effect.promise(() => + Promise.all([ + writeFile(join(supabaseDir, "functions", "auto", "index.ts"), "export {};\n"), + writeFile(join(supabaseDir, "functions", "auto", "deno.json"), "{}\n"), + writeFile(join(supabaseDir, "functions", "hello", "main.ts"), "export {};\n"), + writeFile(join(supabaseDir, "functions", "hello", "deno.json"), "{}\n"), + writeFile(join(supabaseDir, "functions", "disabled", "index.ts"), "export {};\n"), + writeFile( + join(supabaseDir, "functions", ".env"), + "SHARED=dotenv-shared\nDOT_ONLY=dotenv-only\nSUPABASE_URL=dotenv-url\n", + ), + writeFile( + join(supabaseDir, ".env.local"), + "EDGE_VALUE=edge-from-reference\nFUNCTION_VALUE=function-from-reference\nFUNCTION_SHARED=function-shared\nFUNCTION_URL=function-url\n", + ), + writeFile( + join(supabaseDir, "config.toml"), + `[edge_runtime.secrets] +shared = "edge-shared" +edge_only = "env(EDGE_VALUE)" +missing = "env(DOES_NOT_EXIST)" +SUPABASE_URL = "edge-url" + +[functions.hello] +verify_jwt = false +entrypoint = "./functions/hello/main.ts" +import_map = "./functions/hello/deno.json" +static_files = ["./functions/hello/assets/*"] + +[functions.hello.env] +SHARED = "env(FUNCTION_SHARED)" +FUNCTION_ONLY = "env(FUNCTION_VALUE)" +SUPABASE_URL = "env(FUNCTION_URL)" + +[functions.disabled] +enabled = false + +[functions.manual] +entrypoint = "./functions/manual.ts" +`, + ), + ]), + ); + + const projectEnvironment = yield* loadProjectEnvironment({ cwd: projectRoot, baseEnv: {} }); + const loadedProjectConfig = yield* loadProjectConfig( + projectRoot, + projectEnvironment === null ? {} : { projectEnv: projectEnvironment }, + ); + const bundle = yield* translateStartFunctionsStackConfig({ + loadedProjectConfig, + projectEnvironment, + projectRoot, + configDir: supabaseDir, + envFilePath: join(supabaseDir, "functions", ".env"), + }); + + expect(bundle.env).toEqual({ + SHARED: "dotenv-shared", + EDGE_ONLY: "edge-from-reference", + SUPABASE_URL: "dotenv-url", + DOT_ONLY: "dotenv-only", + }); + expect(bundle.env).not.toHaveProperty("MISSING"); + expect(bundle.functions.map(({ name }) => name)).toEqual(["auto", "hello", "manual"]); + expect(bundle.functions[0]).toMatchObject({ + name: "auto", + verifyJWT: true, + entrypointPath: join(supabaseDir, "functions", "auto", "index.ts"), + importMapPath: join(supabaseDir, "functions", "auto", "deno.json"), + }); + expect(bundle.functions[1]).toEqual({ + name: "hello", + verifyJWT: false, + entrypointPath: join(supabaseDir, "functions", "hello", "main.ts"), + importMapPath: join(supabaseDir, "functions", "hello", "deno.json"), + staticFiles: [join(supabaseDir, "functions", "hello", "assets", "*")], + env: { + SHARED: "function-shared", + FUNCTION_ONLY: "function-from-reference", + SUPABASE_URL: "function-url", + }, + }); + expect(bundle.functions[2]).toMatchObject({ + name: "manual", + entrypointPath: join(supabaseDir, "functions", "manual.ts"), + importMapPath: null, + }); + }).pipe( + Effect.provide(BunServices.layer), + Effect.ensuring(Effect.promise(() => rm(projectRoot, { recursive: true, force: true }))), + ); + }); + + it.live("reports dotenv failures without retaining resolved secret values", () => { + const projectRoot = makeProject(); + + return Effect.gen(function* () { + const supabaseDir = join(projectRoot, "supabase"); + yield* Effect.promise(() => mkdir(join(supabaseDir, "functions"), { recursive: true })); + yield* Effect.promise(() => + writeFile( + join(supabaseDir, "functions", ".env"), + "VALID_SECRET=private-functions-value\ninvalid private-functions-value\n", + ), + ); + + const exit = yield* translateStartFunctionsStackConfig({ + loadedProjectConfig: null, + projectEnvironment: null, + projectRoot, + configDir: supabaseDir, + envFilePath: join(supabaseDir, "functions", ".env"), + }).pipe(Effect.exit); + + expect(Exit.isFailure(exit)).toBe(true); + expect(JSON.stringify(exit)).not.toContain("private-functions-value"); + }).pipe( + Effect.provide(BunServices.layer), + Effect.ensuring(Effect.promise(() => rm(projectRoot, { recursive: true, force: true }))), + ); + }); +}); diff --git a/apps/cli/src/next/config/local-stack-config-parity.ts b/apps/cli/src/next/config/local-stack-config-parity.ts new file mode 100644 index 0000000000..8924a62e47 --- /dev/null +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -0,0 +1,728 @@ +import type { ProjectConfig } from "@supabase/config"; + +/** + * The disposition of one project-config leaf in the next local-stack flow. + * + * `presence` tells the future launch resolver whether the decoded value is + * sufficient or whether it must also inspect the loaded source document. Most + * schema defaults erase the distinction between an omitted field and an + * explicitly configured default value, which matters when unsupported fields + * must be rejected or warned about without rejecting untouched defaults. + */ +type LocalStackConfigParityPresence = + | "decoded-value" + | "effective-global-secret" + | "effective-secret" + | "enabled-subtree" + | "non-default-value" + | "raw-document"; + +export type LocalStackConfigParityDecision = + | { + readonly _tag: "mapped"; + readonly presence: LocalStackConfigParityPresence; + readonly mappedBy: "start" | "functions-dev" | "stack-functions-runtime"; + readonly rationale: string; + } + | { + readonly _tag: "not-applicable"; + readonly presence: LocalStackConfigParityPresence; + readonly rationale: string; + } + | { + readonly _tag: "unsupported-blocking"; + readonly presence: LocalStackConfigParityPresence; + readonly rationale: string; + } + | { + readonly _tag: "unsupported-warning"; + readonly presence: LocalStackConfigParityPresence; + readonly rationale: string; + }; + +export interface LocalStackConfigParitySection { + readonly [field: string]: Node; +} + +interface LocalStackConfigParityBranch { + readonly decision: LocalStackConfigParityDecision; + readonly children: LocalStackConfigParitySection; +} + +type Node = + | LocalStackConfigParityDecision + | LocalStackConfigParityBranch + | LocalStackConfigParitySection; + +const unsupportedRuntimeField: LocalStackConfigParityDecision = { + _tag: "unsupported-blocking", + presence: "raw-document", + rationale: + "An explicit value changes local runtime behavior but the next stack launch Adapter does not translate it yet.", +}; + +const unsupportedOptionalRuntimeField: LocalStackConfigParityDecision = { + _tag: "unsupported-blocking", + presence: "decoded-value", + rationale: + "An explicitly present optional value changes local runtime behavior but the next stack launch Adapter does not translate it yet.", +}; + +const unsupportedSecretRuntimeField: LocalStackConfigParityDecision = { + _tag: "unsupported-blocking", + presence: "effective-secret", + rationale: + "A concrete resolved secret in an enabled runtime subtree changes local credentials but the next stack launch Adapter does not translate it yet; unresolved generated env placeholders do not count.", +}; + +const unsupportedNonDefaultRuntimeField: LocalStackConfigParityDecision = { + _tag: "unsupported-blocking", + presence: "non-default-value", + rationale: + "Only a value that differs from the generated project-config default changes local runtime behavior.", +}; + +const unsupportedEnabledSubtreeField: LocalStackConfigParityDecision = { + _tag: "unsupported-blocking", + presence: "enabled-subtree", + rationale: + "This setting changes local runtime behavior only when its enclosing feature is effectively enabled; generated disabled stubs do not count.", +}; + +const mappedAutoExposeNewTables: LocalStackConfigParityDecision = { + _tag: "mapped", + presence: "raw-document", + mappedBy: "start", + rationale: + "The start command resolves the tri-state value, emits its deprecation warning, and passes it to PostgreSQL initialization.", +}; + +const mappedDatabaseHealthTimeout: LocalStackConfigParityDecision = { + _tag: "mapped", + presence: "raw-document", + mappedBy: "start", + rationale: + "The launch Adapter resolves the legacy environment override, applies the duration to PostgreSQL startup health, and derives the stack readiness deadline from it.", +}; + +const mappedDatabaseSeedField: LocalStackConfigParityDecision = { + _tag: "mapped", + presence: "raw-document", + mappedBy: "start", + rationale: + "The launch Adapter expands ordered seed inputs and the stack executes them as an internal PostgreSQL bootstrap phase with legacy-compatible seed history semantics.", +}; + +const mappedDatabaseMigrationsEnabled: LocalStackConfigParityDecision = { + _tag: "mapped", + presence: "raw-document", + mappedBy: "start", + rationale: + "The launch Adapter consumes this gate before migration discovery; disabled skips discovery, while enabled dynamically blocks only when conventional migration files are present.", +}; + +const mappedCoreTopologyField: LocalStackConfigParityDecision = { + _tag: "mapped", + presence: "raw-document", + mappedBy: "start", + rationale: + "The launch Adapter applies project values, legacy environment overrides, and CLI exclusions before constructing StackConfig.", +}; + +const mappedDataPlaneRuntimeField: LocalStackConfigParityDecision = { + _tag: "mapped", + presence: "raw-document", + mappedBy: "start", + rationale: + "The data-plane launch module applies the project value and legacy environment override to the service factory runtime.", +}; + +const mappedOptionalDataPlaneRuntimeField: LocalStackConfigParityDecision = { + ...mappedDataPlaneRuntimeField, + presence: "decoded-value", +}; + +const mappedAuthRuntimeField: LocalStackConfigParityDecision = { + _tag: "mapped", + presence: "raw-document", + mappedBy: "start", + rationale: + "The Auth launch translator passes this project setting to the stack-owned Auth runtime configuration.", +}; + +const mappedAuthOptionalRuntimeField: LocalStackConfigParityDecision = { + ...mappedAuthRuntimeField, + presence: "decoded-value", +}; + +const mappedAuthSecretRuntimeField: LocalStackConfigParityDecision = { + ...mappedAuthRuntimeField, + presence: "decoded-value", + rationale: + "The Auth launch translator passes this credential to the stack without retaining it in diagnostics.", +}; + +const mappedAuthEnabledSubtreeField: LocalStackConfigParityDecision = { + ...mappedAuthRuntimeField, + presence: "enabled-subtree", + rationale: + "The Auth launch translator maps this field when its enclosing feature is effectively enabled; generated disabled stubs do not affect the runtime.", +}; + +const mappedAuthEnabledSecretRuntimeField: LocalStackConfigParityDecision = { + ...mappedAuthSecretRuntimeField, + presence: "effective-secret", + rationale: + "The Auth launch translator maps this credential only in an enabled runtime subtree and does not retain it in diagnostics; unresolved generated env placeholders do not count.", +}; + +const mappedNormalizedCoreTopologyField: LocalStackConfigParityDecision = { + ...mappedCoreTopologyField, + presence: "non-default-value", + rationale: + "The launch Adapter normalizes the generated project-config default to the effective stack endpoint and preserves genuine custom values.", +}; + +const projectIdentityField: LocalStackConfigParityDecision = { + _tag: "not-applicable", + presence: "raw-document", + rationale: + "Project identity and managed state paths are resolved before the launch Adapter; this value does not configure a stack runtime.", +}; + +const mappedFunctionManifest: LocalStackConfigParityDecision = { + _tag: "mapped", + presence: "raw-document", + mappedBy: "start", + rationale: + "The start launch translator resolves every configured and discovered function entry, including enablement, JWT verification, absolute paths, static files, and per-function environment values.", +}; + +const functionConfigParity = { + enabled: mappedFunctionManifest, + verify_jwt: mappedFunctionManifest, + import_map: mappedFunctionManifest, + entrypoint: mappedFunctionManifest, + static_files: mappedFunctionManifest, + env: mappedFunctionManifest, +} satisfies Record; + +const mappedStartFunctionsEnvironment: LocalStackConfigParityDecision = { + _tag: "mapped", + presence: "raw-document", + mappedBy: "start", + rationale: + "The start launch translator resolves Edge Runtime secrets into the shared Functions environment before the bundle crosses the daemon reload transport.", +}; + +const commandOnlyDatabaseField: LocalStackConfigParityDecision = { + _tag: "not-applicable", + presence: "raw-document", + rationale: + "This field configures database tooling outside local stack startup and does not belong in StackConfig.", +}; + +const hostedConfigurationField: LocalStackConfigParityDecision = { + _tag: "not-applicable", + presence: "decoded-value", + rationale: + "This hosted-service limit is used by configuration management but does not change the local stack runtime.", +}; +const legacyIgnoredLocalRuntimeField: LocalStackConfigParityDecision = { + _tag: "not-applicable", + presence: "raw-document", + rationale: + "The legacy local start runtime ignores this field, so it cannot change local stack behavior.", +}; +const remoteOverlayField: LocalStackConfigParityDecision = { + _tag: "not-applicable", + presence: "raw-document", + rationale: + "Remote overlays are selected and merged by project configuration resolution before the local stack launch Adapter runs.", +}; + +const unsupportedFutureRuntimeField: LocalStackConfigParityDecision = { + _tag: "unsupported-warning", + presence: "raw-document", + rationale: + "This experimental field has no stable local stack contract yet; an explicit value must be surfaced rather than silently ignored.", +}; + +const ordinaryStartInspectorField: LocalStackConfigParityDecision = { + _tag: "not-applicable", + presence: "raw-document", + rationale: + "Legacy ordinary start never enables Edge Runtime inspector mode; the field belongs to an explicit functions debugging workflow rather than stack startup.", +}; + +const unsupportedStorageBucket: LocalStackConfigParityDecision = { + _tag: "unsupported-blocking", + presence: "raw-document", + rationale: + "Declaring a bucket changes legacy startup behavior even when every bucket property uses its default, but the next stack does not seed Storage buckets yet.", +}; + +const authExternalProviderParity = { + enabled: mappedAuthEnabledSubtreeField, + client_id: mappedAuthEnabledSubtreeField, + secret: mappedAuthEnabledSecretRuntimeField, + url: mappedAuthEnabledSubtreeField, + redirect_uri: mappedAuthEnabledSubtreeField, + skip_nonce_check: mappedAuthEnabledSubtreeField, + email_optional: mappedAuthEnabledSubtreeField, +} satisfies Record; + +const authHookParity = { + enabled: mappedAuthEnabledSubtreeField, + uri: mappedAuthEnabledSubtreeField, + secrets: mappedAuthEnabledSecretRuntimeField, +} satisfies Record; + +const authRateLimitParity = { + email_sent: legacyIgnoredLocalRuntimeField, + sms_sent: unsupportedRuntimeField, + anonymous_users: unsupportedRuntimeField, + token_refresh: unsupportedRuntimeField, + sign_in_sign_ups: unsupportedRuntimeField, + token_verifications: unsupportedRuntimeField, + web3: unsupportedRuntimeField, +} satisfies Record; + +const authExternalParity = { + apple: authExternalProviderParity, + azure: authExternalProviderParity, + bitbucket: authExternalProviderParity, + discord: authExternalProviderParity, + facebook: authExternalProviderParity, + github: authExternalProviderParity, + gitlab: authExternalProviderParity, + google: authExternalProviderParity, + kakao: authExternalProviderParity, + keycloak: authExternalProviderParity, + linkedin_oidc: authExternalProviderParity, + notion: authExternalProviderParity, + twitch: authExternalProviderParity, + twitter: authExternalProviderParity, + x: authExternalProviderParity, + slack_oidc: authExternalProviderParity, + spotify: authExternalProviderParity, + workos: authExternalProviderParity, + zoom: authExternalProviderParity, +} satisfies Record; + +const authExternalWithCustomParity = { + ...authExternalParity, + "*": { + decision: unsupportedEnabledSubtreeField, + children: authExternalProviderParity, + }, +} satisfies LocalStackConfigParitySection; + +const authHooksParity = { + mfa_verification_attempt: authHookParity, + password_verification_attempt: authHookParity, + custom_access_token: authHookParity, + send_sms: authHookParity, + send_email: authHookParity, + before_user_created: authHookParity, +} satisfies Record; + +const authSmsParity = { + enable_signup: mappedAuthRuntimeField, + enable_confirmations: mappedAuthRuntimeField, + template: mappedAuthRuntimeField, + max_frequency: mappedAuthRuntimeField, + twilio: { + enabled: mappedAuthEnabledSubtreeField, + account_sid: mappedAuthEnabledSubtreeField, + message_service_sid: mappedAuthEnabledSubtreeField, + auth_token: mappedAuthEnabledSecretRuntimeField, + } satisfies Record, + twilio_verify: { + enabled: mappedAuthEnabledSubtreeField, + account_sid: mappedAuthEnabledSubtreeField, + message_service_sid: mappedAuthEnabledSubtreeField, + auth_token: mappedAuthEnabledSecretRuntimeField, + } satisfies Record, + messagebird: { + enabled: mappedAuthEnabledSubtreeField, + originator: mappedAuthEnabledSubtreeField, + access_key: mappedAuthEnabledSecretRuntimeField, + } satisfies Record, + textlocal: { + enabled: mappedAuthEnabledSubtreeField, + sender: mappedAuthEnabledSubtreeField, + api_key: mappedAuthEnabledSecretRuntimeField, + } satisfies Record, + vonage: { + enabled: mappedAuthEnabledSubtreeField, + from: mappedAuthEnabledSubtreeField, + api_key: mappedAuthEnabledSubtreeField, + api_secret: mappedAuthEnabledSecretRuntimeField, + } satisfies Record, + test_otp: mappedAuthOptionalRuntimeField, +} satisfies Record; + +const authParity = { + enabled: mappedAuthRuntimeField, + external_url: unsupportedRuntimeField, + passkey: { + enabled: unsupportedEnabledSubtreeField, + }, + webauthn: { + rp_display_name: unsupportedRuntimeField, + rp_id: unsupportedRuntimeField, + rp_origins: unsupportedRuntimeField, + }, + site_url: mappedAuthRuntimeField, + additional_redirect_urls: mappedAuthRuntimeField, + jwt_expiry: mappedAuthRuntimeField, + jwt_issuer: mappedAuthOptionalRuntimeField, + signing_keys_path: mappedAuthOptionalRuntimeField, + enable_refresh_token_rotation: mappedAuthRuntimeField, + refresh_token_reuse_interval: mappedAuthRuntimeField, + enable_manual_linking: mappedAuthRuntimeField, + enable_signup: mappedAuthRuntimeField, + enable_anonymous_sign_ins: mappedAuthRuntimeField, + minimum_password_length: mappedAuthRuntimeField, + password_requirements: mappedAuthRuntimeField, + publishable_key: mappedAuthSecretRuntimeField, + secret_key: mappedAuthSecretRuntimeField, + jwt_secret: mappedAuthSecretRuntimeField, + anon_key: mappedAuthSecretRuntimeField, + service_role_key: mappedAuthSecretRuntimeField, + rate_limit: authRateLimitParity, + captcha: { + enabled: unsupportedRuntimeField, + provider: unsupportedOptionalRuntimeField, + secret: unsupportedSecretRuntimeField, + } satisfies Record, Node>, + hook: authHooksParity, + mfa: { + totp: { + enroll_enabled: unsupportedNonDefaultRuntimeField, + verify_enabled: unsupportedNonDefaultRuntimeField, + } satisfies Record, + phone: { + enroll_enabled: unsupportedNonDefaultRuntimeField, + verify_enabled: unsupportedNonDefaultRuntimeField, + otp_length: unsupportedEnabledSubtreeField, + template: unsupportedEnabledSubtreeField, + max_frequency: unsupportedEnabledSubtreeField, + } satisfies Record, + web_authn: { + enroll_enabled: unsupportedNonDefaultRuntimeField, + verify_enabled: unsupportedNonDefaultRuntimeField, + } satisfies Record, + max_enrolled_factors: unsupportedNonDefaultRuntimeField, + } satisfies Record, + sessions: { + timebox: unsupportedOptionalRuntimeField, + inactivity_timeout: unsupportedOptionalRuntimeField, + } satisfies Record, Node>, + email: { + enable_signup: mappedAuthRuntimeField, + double_confirm_changes: mappedAuthRuntimeField, + enable_confirmations: mappedAuthRuntimeField, + secure_password_change: mappedAuthRuntimeField, + max_frequency: mappedAuthRuntimeField, + otp_length: mappedAuthRuntimeField, + otp_expiry: mappedAuthRuntimeField, + smtp: { + enabled: mappedAuthEnabledSubtreeField, + host: mappedAuthEnabledSubtreeField, + port: mappedAuthEnabledSubtreeField, + user: mappedAuthEnabledSubtreeField, + pass: mappedAuthEnabledSecretRuntimeField, + admin_email: mappedAuthEnabledSubtreeField, + sender_name: mappedAuthEnabledSubtreeField, + } satisfies Record, Node>, + template: { + "*": { + subject: unsupportedRuntimeField, + content_path: unsupportedRuntimeField, + } satisfies Record, + }, + notification: { + "*": { + enabled: unsupportedRuntimeField, + subject: unsupportedRuntimeField, + content_path: unsupportedRuntimeField, + } satisfies Record, + }, + } satisfies Record, + sms: authSmsParity, + external: authExternalWithCustomParity, + web3: { + solana: { + enabled: unsupportedNonDefaultRuntimeField, + } satisfies Record, + ethereum: { + enabled: unsupportedNonDefaultRuntimeField, + } satisfies Record, + } satisfies Record, + oauth_server: { + enabled: unsupportedEnabledSubtreeField, + authorization_url_path: unsupportedEnabledSubtreeField, + allow_dynamic_registration: unsupportedEnabledSubtreeField, + } satisfies Record, + third_party: { + firebase: { + enabled: unsupportedEnabledSubtreeField, + project_id: unsupportedEnabledSubtreeField, + } satisfies Record, + auth0: { + enabled: unsupportedEnabledSubtreeField, + tenant: unsupportedEnabledSubtreeField, + tenant_region: unsupportedEnabledSubtreeField, + } satisfies Record, + aws_cognito: { + enabled: unsupportedEnabledSubtreeField, + user_pool_id: unsupportedEnabledSubtreeField, + user_pool_region: unsupportedEnabledSubtreeField, + } satisfies Record, + clerk: { + enabled: unsupportedEnabledSubtreeField, + domain: unsupportedEnabledSubtreeField, + } satisfies Record, + workos: { + enabled: unsupportedEnabledSubtreeField, + issuer_url: unsupportedEnabledSubtreeField, + } satisfies Record, + } satisfies Record, +} satisfies Record & LocalStackConfigParitySection; + +const dbSettingsParity = { + effective_cache_size: unsupportedOptionalRuntimeField, + logical_decoding_work_mem: unsupportedOptionalRuntimeField, + maintenance_work_mem: unsupportedOptionalRuntimeField, + max_connections: unsupportedOptionalRuntimeField, + max_locks_per_transaction: unsupportedOptionalRuntimeField, + max_parallel_maintenance_workers: unsupportedOptionalRuntimeField, + max_parallel_workers: unsupportedOptionalRuntimeField, + max_parallel_workers_per_gather: unsupportedOptionalRuntimeField, + max_replication_slots: unsupportedOptionalRuntimeField, + max_slot_wal_keep_size: unsupportedOptionalRuntimeField, + max_standby_archive_delay: unsupportedOptionalRuntimeField, + max_standby_streaming_delay: unsupportedOptionalRuntimeField, + max_wal_size: unsupportedOptionalRuntimeField, + max_wal_senders: unsupportedOptionalRuntimeField, + max_worker_processes: unsupportedOptionalRuntimeField, + session_replication_role: unsupportedOptionalRuntimeField, + shared_buffers: unsupportedOptionalRuntimeField, + statement_timeout: unsupportedOptionalRuntimeField, + track_activity_query_size: unsupportedOptionalRuntimeField, + track_commit_timestamp: unsupportedOptionalRuntimeField, + wal_keep_size: unsupportedOptionalRuntimeField, + wal_sender_timeout: unsupportedOptionalRuntimeField, + work_mem: unsupportedOptionalRuntimeField, +} satisfies Record, Node>; + +/** + * Executable inventory for the current next local-stack implementation. + * + * Every fixed project-config object is checked against its schema-derived + * `keyof` type. Adding or removing a field in `@supabase/config` therefore + * requires an explicit parity decision here before the CLI type-check passes. + * Dynamic records use `*` for user-provided keys while their fixed value shape + * is checked exhaustively. Scalar-valued records such as vault and secrets are + * classified at the record field itself. + */ +const localStackConfigParity = { + project_id: projectIdentityField, + analytics: { + enabled: mappedCoreTopologyField, + port: mappedCoreTopologyField, + backend: mappedCoreTopologyField, + vector_port: unsupportedOptionalRuntimeField, + gcp_project_id: mappedOptionalDataPlaneRuntimeField, + gcp_project_number: mappedOptionalDataPlaneRuntimeField, + gcp_jwt_path: mappedOptionalDataPlaneRuntimeField, + } satisfies Record, + api: { + enabled: mappedCoreTopologyField, + port: mappedCoreTopologyField, + schemas: mappedCoreTopologyField, + extra_search_path: mappedCoreTopologyField, + max_rows: mappedCoreTopologyField, + auto_expose_new_tables: mappedAutoExposeNewTables, + tls: { + enabled: unsupportedEnabledSubtreeField, + cert_path: unsupportedEnabledSubtreeField, + key_path: unsupportedEnabledSubtreeField, + } satisfies Record, + external_url: unsupportedOptionalRuntimeField, + } satisfies Record, + auth: authParity, + db: { + port: mappedCoreTopologyField, + shadow_port: commandOnlyDatabaseField, + health_timeout: mappedDatabaseHealthTimeout, + major_version: unsupportedNonDefaultRuntimeField, + pooler: { + enabled: mappedCoreTopologyField, + port: mappedCoreTopologyField, + pool_mode: mappedCoreTopologyField, + default_pool_size: mappedCoreTopologyField, + max_client_conn: mappedCoreTopologyField, + } satisfies Record, + migrations: { + enabled: mappedDatabaseMigrationsEnabled, + schema_paths: unsupportedRuntimeField, + } satisfies Record, + seed: { + enabled: mappedDatabaseSeedField, + sql_paths: mappedDatabaseSeedField, + } satisfies Record, + settings: dbSettingsParity, + network_restrictions: { + enabled: commandOnlyDatabaseField, + allowed_cidrs: commandOnlyDatabaseField, + allowed_cidrs_v6: commandOnlyDatabaseField, + } satisfies Record, + ssl_enforcement: { + enabled: unsupportedRuntimeField, + } satisfies Record, Node>, + vault: unsupportedSecretRuntimeField, + } satisfies Record, + edge_runtime: { + enabled: mappedCoreTopologyField, + policy: mappedCoreTopologyField, + inspector_port: ordinaryStartInspectorField, + deno_version: unsupportedNonDefaultRuntimeField, + secrets: mappedStartFunctionsEnvironment, + } satisfies Record, + functions: { + "*": functionConfigParity, + }, + local_smtp: { + enabled: mappedCoreTopologyField, + port: mappedCoreTopologyField, + smtp_port: mappedCoreTopologyField, + pop3_port: mappedCoreTopologyField, + admin_email: mappedCoreTopologyField, + sender_name: mappedCoreTopologyField, + } satisfies Record, + realtime: { + enabled: mappedCoreTopologyField, + ip_version: mappedDataPlaneRuntimeField, + max_header_length: mappedCoreTopologyField, + } satisfies Record, + storage: { + enabled: mappedCoreTopologyField, + file_size_limit: mappedCoreTopologyField, + image_transformation: { + enabled: mappedCoreTopologyField, + } satisfies Record, Node>, + buckets: { + "*": { + decision: unsupportedStorageBucket, + children: { + public: unsupportedRuntimeField, + file_size_limit: unsupportedRuntimeField, + allowed_mime_types: unsupportedRuntimeField, + objects_path: unsupportedRuntimeField, + } satisfies Record[string], Node>, + } satisfies LocalStackConfigParityBranch, + }, + s3_protocol: { + enabled: mappedCoreTopologyField, + } satisfies Record, + analytics: { + enabled: unsupportedEnabledSubtreeField, + max_namespaces: unsupportedEnabledSubtreeField, + max_tables: unsupportedEnabledSubtreeField, + max_catalogs: unsupportedEnabledSubtreeField, + buckets: { + "*": { + decision: unsupportedEnabledSubtreeField, + children: {} satisfies Record< + keyof ProjectConfig["storage"]["analytics"]["buckets"][string], + Node + >, + }, + }, + } satisfies Record, + vector: { + enabled: mappedDataPlaneRuntimeField, + max_buckets: hostedConfigurationField, + max_indexes: hostedConfigurationField, + buckets: { + "*": { + decision: unsupportedEnabledSubtreeField, + children: {} satisfies Record< + keyof ProjectConfig["storage"]["vector"]["buckets"][string], + Node + >, + }, + }, + } satisfies Record, + } satisfies Record, + studio: { + enabled: mappedCoreTopologyField, + port: mappedCoreTopologyField, + api_url: mappedNormalizedCoreTopologyField, + openai_api_key: mappedOptionalDataPlaneRuntimeField, + } satisfies Record, + experimental: { + orioledb_version: unsupportedFutureRuntimeField, + s3_host: unsupportedFutureRuntimeField, + s3_region: unsupportedFutureRuntimeField, + s3_access_key: unsupportedFutureRuntimeField, + s3_secret_key: unsupportedFutureRuntimeField, + webhooks: { + enabled: unsupportedFutureRuntimeField, + } satisfies Record, Node>, + pgdelta: { + enabled: commandOnlyDatabaseField, + declarative_schema_path: commandOnlyDatabaseField, + format_options: commandOnlyDatabaseField, + } satisfies Record, Node>, + inspect: { + rules: commandOnlyDatabaseField, + } satisfies Record, Node>, + } satisfies Record, + remotes: remoteOverlayField, +} satisfies Record; + +export interface LocalStackConfigParityEntry { + readonly path: string; + readonly decision: LocalStackConfigParityDecision; + /** Fixed sibling names that a wildcard at a given path segment must not match. */ + readonly wildcardExclusions: Readonly>>; +} + +function isDecision(node: Node): node is LocalStackConfigParityDecision { + return "_tag" in node; +} + +function isBranch(node: Node): node is LocalStackConfigParityBranch { + return "decision" in node && "children" in node; +} + +/** Flattens the nested, compile-checked ledger for diagnostics and tests. */ +export function flattenLocalStackConfigParity( + section: LocalStackConfigParitySection = localStackConfigParity, + prefix = "", + inheritedWildcardExclusions: Readonly>> = {}, +): ReadonlyArray { + const fixedSiblings = Object.keys(section).filter((field) => field !== "*"); + return Object.entries(section).flatMap(([field, node]) => { + const path = prefix === "" ? field : `${prefix}.${field}`; + const wildcardExclusions = + field === "*" && fixedSiblings.length > 0 + ? { + ...inheritedWildcardExclusions, + [prefix === "" ? 0 : prefix.split(".").length]: fixedSiblings, + } + : inheritedWildcardExclusions; + if (isDecision(node)) return [{ path, decision: node, wildcardExclusions }]; + if (isBranch(node)) { + return [ + { path, decision: node.decision, wildcardExclusions }, + ...flattenLocalStackConfigParity(node.children, path, wildcardExclusions), + ]; + } + return flattenLocalStackConfigParity(node, path, wildcardExclusions); + }); +} diff --git a/apps/cli/src/next/config/local-stack-config-parity.unit.test.ts b/apps/cli/src/next/config/local-stack-config-parity.unit.test.ts new file mode 100644 index 0000000000..ac9cf55495 --- /dev/null +++ b/apps/cli/src/next/config/local-stack-config-parity.unit.test.ts @@ -0,0 +1,182 @@ +import { describe, expect, it } from "vitest"; +import { flattenLocalStackConfigParity } from "./local-stack-config-parity.ts"; + +describe("localStackConfigParity", () => { + const entries = flattenLocalStackConfigParity(); + + it("classifies every fixed project-config leaf exactly once", () => { + const paths = entries.map(({ path }) => path); + + expect(paths).toHaveLength(375); + expect(new Set(paths).size).toBe(paths.length); + expect( + Object.fromEntries( + ["mapped", "not-applicable", "unsupported-blocking", "unsupported-warning"].map((tag) => [ + tag, + entries.filter(({ decision }) => decision._tag === tag).length, + ]), + ), + ).toEqual({ + mapped: 262, + "not-applicable": 14, + "unsupported-blocking": 93, + "unsupported-warning": 6, + }); + }); + + it("maps core topology and Auth while leaving unimplemented domains explicit", () => { + const mappedPaths = entries + .filter(({ decision }) => decision._tag === "mapped") + .map(({ path }) => path); + + expect(mappedPaths.filter((path) => !path.startsWith("auth.")).sort()).toEqual([ + "analytics.backend", + "analytics.enabled", + "analytics.gcp_jwt_path", + "analytics.gcp_project_id", + "analytics.gcp_project_number", + "analytics.port", + "api.auto_expose_new_tables", + "api.enabled", + "api.extra_search_path", + "api.max_rows", + "api.port", + "api.schemas", + "db.health_timeout", + "db.migrations.enabled", + "db.pooler.default_pool_size", + "db.pooler.enabled", + "db.pooler.max_client_conn", + "db.pooler.pool_mode", + "db.pooler.port", + "db.port", + "db.seed.enabled", + "db.seed.sql_paths", + "edge_runtime.enabled", + "edge_runtime.policy", + "edge_runtime.secrets", + "functions.*.enabled", + "functions.*.entrypoint", + "functions.*.env", + "functions.*.import_map", + "functions.*.static_files", + "functions.*.verify_jwt", + "local_smtp.admin_email", + "local_smtp.enabled", + "local_smtp.pop3_port", + "local_smtp.port", + "local_smtp.sender_name", + "local_smtp.smtp_port", + "realtime.enabled", + "realtime.ip_version", + "realtime.max_header_length", + "storage.enabled", + "storage.file_size_limit", + "storage.image_transformation.enabled", + "storage.s3_protocol.enabled", + "storage.vector.enabled", + "studio.api_url", + "studio.enabled", + "studio.openai_api_key", + "studio.port", + ]); + expect(mappedPaths.filter((path) => path.startsWith("auth."))).toHaveLength(213); + expect(mappedPaths).toEqual( + expect.arrayContaining([ + "auth.enabled", + "auth.signing_keys_path", + "auth.email.smtp.pass", + "auth.sms.twilio.auth_token", + "auth.external.github.redirect_uri", + "auth.hook.custom_access_token.secrets", + ]), + ); + expect(mappedPaths).not.toEqual( + expect.arrayContaining([ + "auth.email.template.*.content_path", + "auth.mfa.totp.enroll_enabled", + "auth.rate_limit.email_sent", + ]), + ); + }); + + it("preserves effective presence requirements for presence-sensitive sections", () => { + const byPath = new Map(entries.map(({ path, decision }) => [path, decision])); + + expect(byPath.get("api.auto_expose_new_tables")?.presence).toBe("raw-document"); + expect(byPath.get("auth.external.github.enabled")?.presence).toBe("enabled-subtree"); + expect(byPath.get("auth.external.github.client_id")?.presence).toBe("enabled-subtree"); + expect(byPath.get("auth.hook.send_email.enabled")?.presence).toBe("enabled-subtree"); + expect(byPath.get("auth.hook.send_email.uri")?.presence).toBe("enabled-subtree"); + expect(byPath.get("auth.sms.twilio.enabled")?.presence).toBe("enabled-subtree"); + expect(byPath.get("auth.oauth_server.enabled")?.presence).toBe("enabled-subtree"); + expect(byPath.get("auth.third_party.firebase.project_id")?.presence).toBe("enabled-subtree"); + expect(byPath.get("api.tls.cert_path")?.presence).toBe("enabled-subtree"); + expect(byPath.get("storage.analytics.max_tables")?.presence).toBe("enabled-subtree"); + expect(byPath.get("edge_runtime.deno_version")?.presence).toBe("non-default-value"); + expect(byPath.get("auth.jwt_secret")?.presence).toBe("decoded-value"); + expect(byPath.get("auth.email.smtp.enabled")?.presence).toBe("enabled-subtree"); + expect(byPath.get("auth.email.smtp.host")?.presence).toBe("enabled-subtree"); + expect(byPath.get("auth.email.smtp.pass")?.presence).toBe("effective-secret"); + expect(byPath.get("auth.web3.solana.enabled")?.presence).toBe("non-default-value"); + expect(byPath.get("auth.mfa.totp.enroll_enabled")?.presence).toBe("non-default-value"); + expect(byPath.get("auth.mfa.phone.otp_length")?.presence).toBe("enabled-subtree"); + expect(byPath.get("auth.mfa.max_enrolled_factors")?.presence).toBe("non-default-value"); + expect(byPath.get("db.major_version")?.presence).toBe("non-default-value"); + expect(byPath.get("auth.external_url")?.presence).toBe("raw-document"); + expect(byPath.get("auth.passkey.enabled")?.presence).toBe("enabled-subtree"); + expect(byPath.get("auth.webauthn.rp_id")?.presence).toBe("raw-document"); + expect(byPath.get("studio.api_url")?.presence).toBe("non-default-value"); + expect(byPath.get("auth.external.*")?.presence).toBe("enabled-subtree"); + expect(byPath.get("storage.image_transformation.enabled")?.presence).toBe("raw-document"); + expect(byPath.get("storage.buckets.*")?.presence).toBe("raw-document"); + expect(byPath.get("storage.analytics.buckets.*")?.presence).toBe("enabled-subtree"); + expect(byPath.get("storage.vector.buckets.*")?.presence).toBe("enabled-subtree"); + expect(byPath.get("experimental.webhooks.enabled")?.presence).toBe("raw-document"); + }); + + it("maps the migration discovery gate while leaving schema execution unsupported", () => { + const byPath = new Map(entries.map(({ path, decision }) => [path, decision])); + + expect(byPath.get("db.migrations.enabled")?._tag).toBe("mapped"); + expect(byPath.get("db.migrations.schema_paths")?._tag).toBe("unsupported-blocking"); + }); + + it("keeps non-runtime project configuration out of StackConfig", () => { + expect( + entries + .filter(({ decision }) => decision._tag === "not-applicable") + .map(({ path }) => path) + .sort(), + ).toEqual( + [ + "auth.rate_limit.email_sent", + "db.network_restrictions.allowed_cidrs", + "db.network_restrictions.allowed_cidrs_v6", + "db.network_restrictions.enabled", + "db.shadow_port", + "experimental.inspect.rules", + "experimental.pgdelta.declarative_schema_path", + "experimental.pgdelta.enabled", + "experimental.pgdelta.format_options", + "project_id", + "remotes", + "edge_runtime.inspector_port", + "storage.vector.max_buckets", + "storage.vector.max_indexes", + ].sort(), + ); + }); + + it("keeps bucket seeding and unconsumed quotas blocking", () => { + const byPath = new Map(entries.map(({ path, decision }) => [path, decision])); + for (const path of [ + "storage.buckets.*", + "storage.buckets.*.objects_path", + "storage.buckets.*.public", + "storage.analytics.max_namespaces", + ]) { + expect(byPath.get(path)?._tag).toBe("unsupported-blocking"); + } + }); +}); diff --git a/apps/cli/src/next/config/local-stack-config-values.ts b/apps/cli/src/next/config/local-stack-config-values.ts new file mode 100644 index 0000000000..3737e09e60 --- /dev/null +++ b/apps/cli/src/next/config/local-stack-config-values.ts @@ -0,0 +1,135 @@ +import type { LoadedProjectConfig, ProjectEnvironment } from "@supabase/config"; + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function nestedValue( + root: Readonly> | undefined, + path: ReadonlyArray, +): unknown { + let current: unknown = root; + for (const segment of path) { + if (!isRecord(current)) return undefined; + current = current[segment]; + } + return current; +} + +/** Mirrors Viper's `SUPABASE` prefix and dot-to-underscore key replacer. */ +function legacyEnvironmentName(path: string): string { + return `SUPABASE_${path.replaceAll(".", "_").toUpperCase()}`; +} + +/** + * Whether Go's selected remote supplied this path with `viper.Set`. The + * fallback supports manually constructed LoadedProjectConfig fixtures created + * before path-only remote provenance was added to `@supabase/config`. + */ +function remoteDefinesConfigPath(loaded: LoadedProjectConfig | null, path: string): boolean { + if (loaded?.appliedRemote === undefined) return false; + if (loaded.remoteOverridePaths?.includes(path) === true) return true; + const remotes = isRecord(loaded.document?.remotes) ? loaded.document.remotes : undefined; + const remote = remotes?.[loaded.appliedRemote]; + return isRecord(remote) && nestedValue(remote, path.split(".")) !== undefined; +} + +export function resolveEnvironmentReference( + value: string, + environment: ProjectEnvironment | null, +): string { + const match = /^env\((.*)\)$/.exec(value); + const referencedName = match?.[1]; + if (referencedName === undefined) return value; + const referenced = environment?.values[referencedName]; + return referenced === undefined || referenced.length === 0 ? value : referenced; +} + +/** + * Returns an effective legacy environment binding, excluding empty values and + * paths owned by an applied remote. No caller needs to retain the value merely + * to answer presence; use {@link hasEffectiveEnvironmentOverride} for that. + */ +export function effectiveEnvironmentOverride(input: { + readonly loaded: LoadedProjectConfig | null; + readonly environment: ProjectEnvironment | null; + readonly path: string; +}): string | undefined { + if (remoteDefinesConfigPath(input.loaded, input.path)) return undefined; + const value = input.environment?.values[legacyEnvironmentName(input.path)]; + if (value === undefined || value.length === 0) return undefined; + return resolveEnvironmentReference(value, input.environment); +} + +export function hasEffectiveEnvironmentOverride(input: { + readonly loaded: LoadedProjectConfig | null; + readonly environment: ProjectEnvironment | null; + readonly path: string; +}): boolean { + return effectiveEnvironmentOverride(input) !== undefined; +} + +export function effectiveString(input: { + readonly loaded: LoadedProjectConfig | null; + readonly environment: ProjectEnvironment | null; + readonly path: string; + readonly configured: string; +}): string { + return resolveEnvironmentReference( + effectiveEnvironmentOverride(input) ?? input.configured, + input.environment, + ); +} + +export function effectiveStringList(input: { + readonly loaded: LoadedProjectConfig | null; + readonly environment: ProjectEnvironment | null; + readonly path: string; + readonly configured: ReadonlyArray; +}): ReadonlyArray { + const override = effectiveEnvironmentOverride(input); + return override === undefined ? input.configured : override.split(","); +} + +export function parseGoUint32(value: string): number | undefined { + if (value.length === 0 || value.startsWith("+") || value.startsWith("-")) return undefined; + + let literal: string | undefined; + if (/^0[bB](_?[01])+$/.test(value)) { + literal = `0b${value.slice(2).replaceAll("_", "")}`; + } else if (/^0[oO](_?[0-7])+$/.test(value)) { + literal = `0o${value.slice(2).replaceAll("_", "")}`; + } else if (/^0[xX](_?[0-9a-fA-F])+$/.test(value)) { + literal = `0x${value.slice(2).replaceAll("_", "")}`; + } else if (value.startsWith("0") && value.length > 1) { + literal = /^[0-7](_?[0-7])*$/.test(value) ? `0o${value.replaceAll("_", "")}` : undefined; + } else { + literal = /^[0-9](_?[0-9])*$/.test(value) ? value.replaceAll("_", "") : undefined; + } + if (literal === undefined) return undefined; + try { + const parsed = BigInt(literal); + return parsed <= 4_294_967_295n ? Number(parsed) : undefined; + } catch { + return undefined; + } +} + +const GO_BOOLEAN_VALUES: Readonly> = { + "1": true, + t: true, + T: true, + TRUE: true, + true: true, + True: true, + "0": false, + f: false, + F: false, + FALSE: false, + false: false, + False: false, +}; + +export function parseGoBoolean(value: string): boolean | undefined { + return GO_BOOLEAN_VALUES[value]; +} diff --git a/apps/cli/src/next/config/pooler-stack-config.ts b/apps/cli/src/next/config/pooler-stack-config.ts new file mode 100644 index 0000000000..bd1c6d567a --- /dev/null +++ b/apps/cli/src/next/config/pooler-stack-config.ts @@ -0,0 +1,32 @@ +import type { LoadedProjectConfig, ProjectConfig, ProjectEnvironment } from "@supabase/config"; +import type { PoolerConfig } from "@supabase/stack/effect"; +import { resolveEnumOverride, resolveUintOverride } from "./data-plane-stack-config-values.ts"; + +export function resolvePoolerStackConfig(input: { + readonly loaded: LoadedProjectConfig | null; + readonly config: ProjectConfig["db"]["pooler"]; + readonly environment: ProjectEnvironment | null; + readonly base: PoolerConfig | false | undefined; +}): PoolerConfig | false { + const mode = resolveEnumOverride<"transaction" | "session">({ + loaded: input.loaded, + environment: input.environment, + configured: input.config.pool_mode, + path: "db.pooler.pool_mode", + values: ["transaction", "session"], + }); + const defaultPoolSize = resolveUintOverride({ + loaded: input.loaded, + environment: input.environment, + configured: input.config.default_pool_size, + path: "db.pooler.default_pool_size", + }); + const maxClientConn = resolveUintOverride({ + loaded: input.loaded, + environment: input.environment, + configured: input.config.max_client_conn, + path: "db.pooler.max_client_conn", + }); + + return input.base === false ? false : { ...input.base, mode, defaultPoolSize, maxClientConn }; +} diff --git a/apps/cli/src/next/config/realtime-stack-config.ts b/apps/cli/src/next/config/realtime-stack-config.ts new file mode 100644 index 0000000000..dda52abc6c --- /dev/null +++ b/apps/cli/src/next/config/realtime-stack-config.ts @@ -0,0 +1,26 @@ +import type { LoadedProjectConfig, ProjectConfig, ProjectEnvironment } from "@supabase/config"; +import type { RealtimeConfig } from "@supabase/stack/effect"; +import { resolveEnumOverride, resolveUintOverride } from "./data-plane-stack-config-values.ts"; + +export function resolveRealtimeStackConfig(input: { + readonly loaded: LoadedProjectConfig | null; + readonly config: ProjectConfig["realtime"]; + readonly environment: ProjectEnvironment | null; + readonly base: RealtimeConfig | false | undefined; +}): RealtimeConfig | false { + const ipVersion = resolveEnumOverride<"IPv4" | "IPv6">({ + loaded: input.loaded, + environment: input.environment, + configured: input.config.ip_version, + path: "realtime.ip_version", + values: ["IPv4", "IPv6"], + }); + const maxHeaderLength = resolveUintOverride({ + loaded: input.loaded, + environment: input.environment, + configured: input.config.max_header_length, + path: "realtime.max_header_length", + }); + + return input.base === false ? false : { ...input.base, ipVersion, maxHeaderLength }; +} diff --git a/apps/cli/src/next/config/stack-config.integration.test.ts b/apps/cli/src/next/config/stack-config.integration.test.ts new file mode 100644 index 0000000000..005567eabc --- /dev/null +++ b/apps/cli/src/next/config/stack-config.integration.test.ts @@ -0,0 +1,414 @@ +import { loadProjectConfig, loadProjectEnvironmentFor } from "@supabase/config/node"; +import { BunServices } from "@effect/platform-bun"; +import { Effect } from "effect"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveLocalStackLaunch } from "./stack-config.ts"; + +const resolveLocalStackLaunchWithBun = (input: Parameters[0]) => + resolveLocalStackLaunch(input).pipe(Effect.provide(BunServices.layer)); + +describe("local stack launch config", () => { + it("resolves one project snapshot before translating the launch", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "supabase-local-stack-launch-")); + const supabaseDir = join(projectRoot, "supabase"); + try { + await mkdir(supabaseDir, { recursive: true }); + await writeFile(join(supabaseDir, ".env.local"), "DB_STARTUP_BUDGET=7s\n"); + await writeFile( + join(supabaseDir, "config.toml"), + [ + "[api]", + "auto_expose_new_tables = false", + "", + "[db]", + 'health_timeout = "env(DB_STARTUP_BUDGET)"', + "", + "[experimental.webhooks]", + "enabled = true", + "", + ].join("\n"), + ); + + const projectEnvironment = await loadProjectEnvironmentFor({ cwd: projectRoot, baseEnv: {} }); + expect(projectEnvironment).not.toBeNull(); + if (projectEnvironment === null) { + return; + } + const loadedProjectConfig = await loadProjectConfig(projectRoot, { + projectEnv: projectEnvironment, + }); + const result = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + loadedProjectConfig, + projectEnvironment, + projectPaths: { projectRoot, projectStateRoot: join(projectRoot, ".supabase") }, + mode: "native", + exclude: ["studio"], + runtimeVersions: { postgres: "17.6.1.090" }, + }), + ); + + expect(result.stackConfig).toMatchObject({ + projectDir: projectRoot, + mode: "native", + studio: false, + postgres: { version: "17.6.1.090", autoExposeNewTables: false }, + }); + expect(result.stackConfig.postgres?.startupHealthTimeoutMs).toBe(7_000); + expect(result.stackConfig.readiness).toEqual({ mode: "finite", timeoutMs: 37_000 }); + expect(result.warnings).toEqual([ + expect.objectContaining({ + code: "unsupported", + paths: ["experimental.webhooks.enabled"], + }), + expect.objectContaining({ + code: "unmatched-seed-pattern", + paths: ["db.seed.sql_paths"], + }), + ]); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("translates an Auth project scenario without retaining secret values in diagnostics", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "supabase-local-auth-launch-")); + const supabaseDir = join(projectRoot, "supabase"); + try { + await mkdir(supabaseDir, { recursive: true }); + await writeFile( + join(supabaseDir, ".env.local"), + [ + "AUTH_JWT_SECRET=jwt-secret-with-at-least-32-characters", + "AUTH_SMTP_PASS=smtp-secret", + "AUTH_GITHUB_SECRET=github-secret", + "AUTH_HOOK_SECRET=hook-secret", + ].join("\n"), + ); + await writeFile( + join(supabaseDir, "config.toml"), + [ + "[auth]", + 'site_url = "https://app.example.com"', + 'additional_redirect_urls = ["https://app.example.com/callback"]', + "jwt_expiry = 7200", + 'jwt_secret = "env(AUTH_JWT_SECRET)"', + "enable_signup = false", + "", + "[auth.email]", + "enable_confirmations = true", + "", + "[auth.email.smtp]", + "enabled = true", + 'host = "smtp.example.com"', + "port = 587", + 'user = "mailer"', + 'pass = "env(AUTH_SMTP_PASS)"', + 'admin_email = "admin@example.com"', + "", + "[auth.external.github]", + "enabled = true", + 'client_id = "github-client"', + 'secret = "env(AUTH_GITHUB_SECRET)"', + "", + "[auth.hook.custom_access_token]", + "enabled = true", + 'uri = "pg-functions://postgres/auth/custom-access-token"', + 'secrets = "env(AUTH_HOOK_SECRET)"', + "", + ].join("\n"), + ); + + const projectEnvironment = await loadProjectEnvironmentFor({ cwd: projectRoot, baseEnv: {} }); + expect(projectEnvironment).not.toBeNull(); + if (projectEnvironment === null) return; + const loadedProjectConfig = await loadProjectConfig(projectRoot, { + projectEnv: projectEnvironment, + }); + const result = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + loadedProjectConfig, + projectEnvironment, + projectPaths: { projectRoot, projectStateRoot: join(projectRoot, ".supabase") }, + mode: "auto", + exclude: [], + runtimeVersions: {}, + }), + ); + + expect(result.stackConfig.credentials?.signing).toEqual({ + _tag: "SymmetricJwtSecret", + secret: "jwt-secret-with-at-least-32-characters", + }); + expect(result.stackConfig.auth).toMatchObject({ + siteUrl: "https://app.example.com", + additionalRedirectUrls: ["https://app.example.com/callback"], + jwtExpiry: 7200, + enableSignup: false, + email: { + enableConfirmations: true, + smtp: { host: "smtp.example.com", pass: "smtp-secret" }, + }, + externalProviders: { + github: { enabled: true, clientId: "github-client", secret: "github-secret" }, + }, + hooks: { + custom_access_token: { enabled: true, secrets: "hook-secret" }, + }, + }); + expect(result.warnings).toEqual([ + expect.objectContaining({ + code: "unmatched-seed-pattern", + paths: ["db.seed.sql_paths"], + }), + ]); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("resolves asymmetric credentials even when Auth is excluded", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "supabase-excluded-auth-credentials-")); + const supabaseDir = join(projectRoot, "supabase"); + try { + await mkdir(supabaseDir, { recursive: true }); + await writeFile( + join(supabaseDir, "signing-keys.json"), + JSON.stringify([ + { + kty: "EC", + kid: "excluded-auth-key", + use: "sig", + alg: "ES256", + crv: "P-256", + x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", + y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", + d: "dIhR8wywJlqlua4y_yMq2SLhlFXDZJBCvFrY1DCHyVU", + }, + ]), + ); + await writeFile( + join(supabaseDir, "config.toml"), + [ + "[auth]", + 'jwt_secret = "legacy-shared-secret-with-at-least-32-characters"', + 'signing_keys_path = "./signing-keys.json"', + "", + ].join("\n"), + ); + + const projectEnvironment = await loadProjectEnvironmentFor({ cwd: projectRoot, baseEnv: {} }); + expect(projectEnvironment).not.toBeNull(); + if (projectEnvironment === null) return; + const loadedProjectConfig = await loadProjectConfig(projectRoot, { + projectEnv: projectEnvironment, + }); + const result = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + loadedProjectConfig, + projectEnvironment, + projectPaths: { projectRoot, projectStateRoot: join(projectRoot, ".supabase") }, + mode: "auto", + exclude: ["auth"], + runtimeVersions: {}, + }), + ); + + expect(result.stackConfig.auth).toBe(false); + expect(result.stackConfig.credentials?.signing).toMatchObject({ + _tag: "AsymmetricJwtKeys", + keys: [expect.objectContaining({ kid: "excluded-auth-key", alg: "ES256" })], + }); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("resolves seed inputs before the stack launch is constructed", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "supabase-local-bootstrap-launch-")); + const supabaseDir = join(projectRoot, "supabase"); + try { + await mkdir(join(supabaseDir, "seeds"), { recursive: true }); + const seedSecond = join(supabaseDir, "seeds", "02_widgets.sql"); + const seedFirst = join(supabaseDir, "seeds", "01_accounts.sql"); + await writeFile(seedFirst, "insert into widgets values (1);"); + await writeFile(seedSecond, "insert into widgets values (2);"); + await writeFile(join(supabaseDir, ".env.local"), "SUPABASE_DB_MIGRATIONS_ENABLED=false\n"); + await writeFile( + join(supabaseDir, "config.toml"), + [ + "[db.seed]", + "enabled = true", + 'sql_paths = ["./seeds/02_widgets.sql", "./seeds/01_accounts.sql"]', + "", + ].join("\n"), + ); + + const projectEnvironment = await loadProjectEnvironmentFor({ cwd: projectRoot, baseEnv: {} }); + expect(projectEnvironment).not.toBeNull(); + if (projectEnvironment === null) return; + const loadedProjectConfig = await loadProjectConfig(projectRoot, { + projectEnv: projectEnvironment, + }); + const result = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + loadedProjectConfig, + projectEnvironment, + projectPaths: { projectRoot, projectStateRoot: join(projectRoot, ".supabase") }, + mode: "auto", + exclude: [], + runtimeVersions: {}, + }), + ); + + expect(result.stackConfig.databaseBootstrap?.seedFiles?.map(({ path }) => path)).toEqual([ + seedSecond, + seedFirst, + ]); + expect( + result.stackConfig.databaseBootstrap?.seedFiles?.map(({ historyPath }) => historyPath), + ).toEqual(["supabase/seeds/02_widgets.sql", "supabase/seeds/01_accounts.sql"]); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("translates data-plane config and environment overrides into runtime inputs", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "supabase-data-plane-launch-")); + const supabaseDir = join(projectRoot, "supabase"); + try { + await mkdir(supabaseDir, { recursive: true }); + await writeFile( + join(supabaseDir, ".env.local"), + [ + "OPENAI_API_KEY=private-openai-key", + "SUPABASE_REALTIME_IP_VERSION=IPv6", + "SUPABASE_REALTIME_MAX_HEADER_LENGTH=8192", + "SUPABASE_STORAGE_FILE_SIZE_LIMIT=5MiB", + "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED=false", + "VECTOR_BUCKET_PROVIDER=custom-provider", + "SUPABASE_ANALYTICS_GCP_PROJECT_ID=environment-project", + "SUPABASE_DB_POOLER_POOL_MODE=session", + "SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE=32", + "SUPABASE_DB_POOLER_MAX_CLIENT_CONN=128", + "", + ].join("\n"), + ); + await writeFile( + join(supabaseDir, "config.toml"), + [ + "[realtime]", + 'ip_version = "IPv4"', + "max_header_length = 4096", + "", + "[storage]", + 'file_size_limit = "50MiB"', + "", + "[storage.s3_protocol]", + "enabled = true", + "", + "[storage.vector]", + "enabled = true", + "", + "[analytics]", + "enabled = true", + 'backend = "bigquery"', + 'gcp_project_id = "config-project"', + 'gcp_project_number = "123"', + 'gcp_jwt_path = "gcp.json"', + "", + "[studio]", + 'openai_api_key = "env(OPENAI_API_KEY)"', + "", + "[db.pooler]", + "enabled = true", + 'pool_mode = "transaction"', + "default_pool_size = 20", + "max_client_conn = 100", + "", + ].join("\n"), + ); + + const projectEnvironment = await loadProjectEnvironmentFor({ cwd: projectRoot, baseEnv: {} }); + expect(projectEnvironment).not.toBeNull(); + if (projectEnvironment === null) return; + const loadedProjectConfig = await loadProjectConfig(projectRoot, { + projectEnv: projectEnvironment, + }); + const result = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + loadedProjectConfig, + projectEnvironment, + projectPaths: { projectRoot, projectStateRoot: join(projectRoot, ".supabase") }, + mode: "docker", + exclude: ["imgproxy"], + runtimeVersions: {}, + }), + ); + + expect(result.stackConfig.realtime).toMatchObject({ + ipVersion: "IPv6", + maxHeaderLength: 8192, + }); + expect(result.stackConfig.storage).toMatchObject({ + fileSizeLimit: "5242880", + s3ProtocolEnabled: false, + vectorRuntime: { provider: "custom-provider" }, + }); + expect(result.stackConfig.imgproxy).toBe(false); + expect(result.stackConfig.analytics).toMatchObject({ + backend: "bigquery", + gcp: { + projectId: "environment-project", + projectNumber: "123", + credentialsPath: join(supabaseDir, "gcp.json"), + }, + }); + expect(result.stackConfig.studio).toMatchObject({ openAiApiKey: "private-openai-key" }); + expect(result.stackConfig.pooler).toMatchObject({ + mode: "session", + defaultPoolSize: 32, + maxClientConn: 128, + }); + expect(result.warnings).toEqual([ + expect.objectContaining({ + code: "unmatched-seed-pattern", + paths: ["db.seed.sql_paths"], + }), + ]); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); + + it("does not discover or read Functions inputs when Edge Runtime is excluded", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "supabase-local-functions-disabled-")); + const supabaseDir = join(projectRoot, "supabase"); + try { + await mkdir(join(supabaseDir, "functions"), { recursive: true }); + await writeFile( + join(supabaseDir, "functions", ".env"), + "VALID_SECRET=private-functions-value\ninvalid private-functions-value\n", + ); + + const result = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + loadedProjectConfig: null, + projectEnvironment: null, + projectPaths: { projectRoot, projectStateRoot: join(projectRoot, ".supabase") }, + mode: "auto", + exclude: ["edge-runtime"], + runtimeVersions: {}, + }), + ); + + expect(result.stackConfig.edgeRuntime).toBe(false); + expect(result.functionsBundle).toBeUndefined(); + } finally { + await rm(projectRoot, { recursive: true, force: true }); + } + }); +}); diff --git a/apps/cli/src/next/config/stack-config.ts b/apps/cli/src/next/config/stack-config.ts index 4b02f31ce3..f3cda3e061 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -1,31 +1,326 @@ -import type { StackConfig, VersionManifest } from "@supabase/stack/effect"; - -export const excludedStackServices = [ - "auth", - "postgrest", - "realtime", - "storage", - "imgproxy", - "mailpit", - "pgmeta", - "studio", - "analytics", - "vector", - "pooler", -] as const; - -export type ExcludedStackService = (typeof excludedStackServices)[number]; +import { + ProjectConfigSchema, + type LoadedProjectConfig, + type ProjectConfig, + type ProjectEnvironment, +} from "@supabase/config"; +import type { + ReadinessPolicy, + ResolvedFunctionsBundle, + StackConfig, + VersionManifest, +} from "@supabase/stack/effect"; +import { Effect, Schema } from "effect"; +import { dirname, join } from "node:path"; +import { legacyParseGoDuration } from "../../shared/config/go-duration.ts"; +import { translateAuthStackConfig } from "./auth-stack-config.ts"; +import { translateStartFunctionsStackConfig } from "./functions-stack-config.ts"; +import { + excludedStackServices, + invalidLocalStackConfig, + LocalStackConfigError, + resolveCoreStackConfig, + type ExcludedStackService, +} from "./core-stack-config.ts"; +import { translateDatabaseBootstrapConfig } from "./database-bootstrap-config.ts"; +import { DataPlaneStackConfigError } from "./data-plane-stack-config-values.ts"; +import { resolveDataPlaneStackConfig } from "./data-plane-stack-config.ts"; +import { + flattenLocalStackConfigParity, + type LocalStackConfigParityDecision, +} from "./local-stack-config-parity.ts"; +import { + effectiveEnvironmentOverride, + hasEffectiveEnvironmentOverride, + parseGoBoolean, +} from "./local-stack-config-values.ts"; + +export { excludedStackServices, LocalStackConfigError, type ExcludedStackService }; export const startModes = ["native", "auto", "docker"] as const; export type StartMode = (typeof startModes)[number]; -export function toStartStackConfig( +const LEGACY_NON_DATABASE_READINESS_BUDGET_MS = 30_000; +const decodeDefaultProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); +const defaultProjectConfig = decodeDefaultProjectConfig({}); + +interface LocalStackProjectPaths { + readonly projectRoot: string; + readonly projectStateRoot: string; +} + +export interface LocalStackLaunchInput { + readonly loadedProjectConfig: LoadedProjectConfig | null; + readonly projectEnvironment: ProjectEnvironment | null; + readonly projectPaths: LocalStackProjectPaths; + readonly mode: StartMode; + readonly exclude: ReadonlyArray; + readonly runtimeVersions: Partial; + /** Managed project launches are lazy; diagnostic callers may request eager startup. */ + readonly startupMode?: "eager" | "lazy"; + /** Interactive diagnostics may opt out of deadlines; ordinary starts are finite. */ + readonly readiness?: "finite" | "infinite"; +} + +export interface LocalStackWarning { + readonly code: "unsupported" | "deprecated" | "unmatched-seed-pattern"; + readonly paths: ReadonlyArray; + readonly message: string; +} + +interface ResolvedLocalStackLaunch { + readonly stackConfig: StackConfig; + readonly functionsBundle: ResolvedFunctionsBundle | undefined; + readonly projectPaths: LocalStackProjectPaths; + readonly warnings: ReadonlyArray; +} + +interface PresentConfigValue { + readonly path: string; + readonly value: unknown; +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function expandPresentValues( + root: unknown, + segments: ReadonlyArray, + prefix = "", + wildcardExclusions: Readonly>> = {}, + segmentIndex = 0, +): ReadonlyArray { + const [segment, ...rest] = segments; + if (segment === undefined) { + return [{ path: prefix, value: root }]; + } + if (!isRecord(root)) { + return []; + } + + if (segment === "*") { + const excluded = new Set(wildcardExclusions[segmentIndex] ?? []); + return Object.entries(root).flatMap(([key, value]) => + excluded.has(key) + ? [] + : expandPresentValues( + value, + rest, + prefix === "" ? key : `${prefix}.${key}`, + wildcardExclusions, + segmentIndex + 1, + ), + ); + } + + if (!(segment in root)) { + return []; + } + return expandPresentValues( + root[segment], + rest, + prefix === "" ? segment : `${prefix}.${segment}`, + wildcardExclusions, + segmentIndex + 1, + ); +} + +function hasMeaningfulDecodedValue(value: unknown): boolean { + if (value === undefined || value === null) { + return false; + } + if (Array.isArray(value)) { + return value.length > 0; + } + if (isRecord(value) && Object.getPrototypeOf(value) === Object.prototype) { + return Object.keys(value).length > 0; + } + return true; +} + +function nestedValue(root: unknown, path: ReadonlyArray): unknown { + let current = root; + for (const segment of path) { + if (!isRecord(current)) return undefined; + current = current[segment]; + } + return current; +} + +function structurallyEqual(left: unknown, right: unknown): boolean { + if (Array.isArray(left) && Array.isArray(right)) { + return ( + left.length === right.length && + left.every((value, index) => structurallyEqual(value, right[index])) + ); + } + if (isRecord(left) && isRecord(right)) { + const leftKeys = Object.keys(left); + const rightKeys = Object.keys(right); + return ( + leftKeys.length === rightKeys.length && + leftKeys.every((key) => key in right && structurallyEqual(left[key], right[key])) + ); + } + return Object.is(left, right); +} + +function effectiveDiagnosticValue(input: { + readonly configured: unknown; + readonly loadedProjectConfig: LoadedProjectConfig | null; + readonly projectEnvironment: ProjectEnvironment | null; + readonly path: string; +}): unknown { + const override = effectiveEnvironmentOverride({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + path: input.path, + }); + if (override === undefined) return input.configured; + if (typeof input.configured === "boolean") return parseGoBoolean(override) ?? override; + if (typeof input.configured === "number") { + const parsed = Number(override); + return Number.isFinite(parsed) ? parsed : override; + } + if (Array.isArray(input.configured)) return override.split(","); + return override; +} + +function isEnabledSubtree(input: { + readonly projectConfig: ProjectConfig; + readonly loadedProjectConfig: LoadedProjectConfig | null; + readonly projectEnvironment: ProjectEnvironment | null; + readonly path: string; +}): boolean { + const segments = input.path.split("."); + for (let length = segments.length; length > 0; length -= 1) { + const ancestorPath = segments.slice(0, length); + const ancestor = nestedValue(input.projectConfig, ancestorPath); + const enabledPath = isRecord(ancestor) + ? [...ancestorPath, "enabled"] + : ancestorPath.at(-1) === "enabled" + ? ancestorPath + : undefined; + if (enabledPath === undefined) continue; + const configured = nestedValue(input.projectConfig, enabledPath); + return ( + effectiveDiagnosticValue({ + configured, + loadedProjectConfig: input.loadedProjectConfig, + projectEnvironment: input.projectEnvironment, + path: enabledPath.join("."), + }) === true + ); + } + return true; +} + +export interface ExplicitLocalStackConfigEntry { + readonly path: string; + readonly decision: LocalStackConfigParityDecision; +} + +/** Resolves presence-sensitive ledger decisions without ever retaining field values. */ +export function explicitLocalStackConfigEntries(input: { + readonly projectConfig: ProjectConfig; + readonly rawDocument?: Readonly>; + readonly loadedProjectConfig?: LoadedProjectConfig | null; + readonly projectEnvironment?: ProjectEnvironment | null; +}): ReadonlyArray { + return flattenLocalStackConfigParity().flatMap(({ path, decision, wildcardExclusions }) => { + const source = decision.presence === "raw-document" ? input.rawDocument : input.projectConfig; + const expanded = + source === undefined + ? [] + : expandPresentValues(source, path.split("."), "", wildcardExclusions); + const concretePaths = expanded.length > 0 ? expanded : path.includes("*") ? [] : [{ path }]; + return concretePaths.flatMap(({ path: explicitPath }) => { + const configured = nestedValue(input.projectConfig, explicitPath.split(".")); + const defaultValue = nestedValue(defaultProjectConfig, explicitPath.split(".")); + const hasEnvironmentOverride = hasEffectiveEnvironmentOverride({ + loaded: input.loadedProjectConfig ?? null, + environment: input.projectEnvironment ?? null, + path: explicitPath, + }); + const effectiveValue = effectiveDiagnosticValue({ + configured, + loadedProjectConfig: input.loadedProjectConfig ?? null, + projectEnvironment: input.projectEnvironment ?? null, + path: explicitPath, + }); + const enabled = isEnabledSubtree({ + projectConfig: input.projectConfig, + loadedProjectConfig: input.loadedProjectConfig ?? null, + projectEnvironment: input.projectEnvironment ?? null, + path: explicitPath, + }); + const present = + hasEnvironmentOverride || + decision.presence === "raw-document" || + decision.presence === "effective-global-secret" + ? hasMeaningfulDecodedValue(effectiveValue) || decision.presence === "raw-document" + : decision.presence === "effective-secret" || decision.presence === "enabled-subtree" + ? enabled && hasMeaningfulDecodedValue(effectiveValue) + : decision.presence === "non-default-value" + ? !structurallyEqual(effectiveValue, defaultValue) + : hasMeaningfulDecodedValue(effectiveValue); + if (!present) return []; + if ( + (decision._tag === "unsupported-blocking" || decision._tag === "unsupported-warning") && + !hasEnvironmentOverride && + structurallyEqual(configured, defaultValue) + ) { + return []; + } + return [{ path: explicitPath, decision }]; + }); + }); +} + +function diagnosticsFor(input: { + readonly projectConfig: ProjectConfig; + readonly rawDocument?: Readonly>; + readonly loadedProjectConfig: LoadedProjectConfig | null; + readonly projectEnvironment: ProjectEnvironment | null; +}): { + readonly warnings: ReadonlyArray; + readonly blockingPaths: ReadonlyArray; +} { + const entries = explicitLocalStackConfigEntries(input); + const warningPaths = entries + .filter(({ decision }) => decision._tag === "unsupported-warning") + .map(({ path }) => path) + .sort(); + const blockingPaths = entries + .filter(({ decision }) => decision._tag === "unsupported-blocking") + .map(({ path }) => path) + .sort(); + + return { + warnings: + warningPaths.length === 0 + ? [] + : [ + { + code: "unsupported", + paths: warningPaths, + message: `The next local stack does not yet apply these experimental settings: ${warningPaths.join(", ")}.`, + }, + ], + blockingPaths, + }; +} + +export function baseStackConfig( exclude: ReadonlyArray, mode: StartMode, + startupMode: "eager" | "lazy" = "lazy", ): StackConfig { const excluded = new Set(exclude); return { mode, - startupMode: "lazy", + startupMode, + edgeRuntime: excluded.has("edge-runtime") ? false : {}, realtime: excluded.has("realtime") ? false : {}, storage: excluded.has("storage") ? false : {}, imgproxy: excluded.has("imgproxy") || excluded.has("storage") ? false : {}, @@ -40,7 +335,7 @@ export function toStartStackConfig( }; } -export function withServiceVersions( +function withServiceVersions( stackConfig: StackConfig, versions: Partial, ): StackConfig { @@ -96,3 +391,217 @@ export function withServiceVersions( : { ...stackConfig.pooler, version: versions.pooler }, }; } + +export function resolveStoredStackLaunch(input: { + readonly exclude: ReadonlyArray; + readonly mode: StartMode; + readonly runtimeVersions: Partial; + readonly startupMode?: "eager" | "lazy"; +}): StackConfig { + return withServiceVersions( + baseStackConfig(input.exclude, input.mode, input.startupMode), + input.runtimeVersions, + ); +} + +export function resolveFunctionsDevStackLaunch( + runtimeVersions: Partial, +): StackConfig { + return resolveStoredStackLaunch({ exclude: [], mode: "auto", runtimeVersions }); +} + +function resolvePostgresStartupTimeout(input: { + readonly projectConfig: ProjectConfig; + readonly projectEnvironment: ProjectEnvironment | null; +}): Effect.Effect { + const configured = + input.projectEnvironment?.values["SUPABASE_DB_HEALTH_TIMEOUT"] ?? + input.projectConfig.db.health_timeout; + + return Effect.try({ + try: () => { + const postgresStartupTimeoutMs = Math.trunc(legacyParseGoDuration(configured) / 1_000_000); + if (postgresStartupTimeoutMs < 0) { + throw new Error("duration must not be negative"); + } + return postgresStartupTimeoutMs; + }, + catch: () => + invalidLocalStackConfig( + "db.health_timeout", + "Use a non-negative Go duration such as 2m or 30s.", + ), + }); +} + +export const resolveLocalStackLaunch = Effect.fnUntraced(function* (input: LocalStackLaunchInput) { + const projectConfig = input.loadedProjectConfig?.config ?? defaultProjectConfig; + const postgresStartupTimeoutMs = yield* resolvePostgresStartupTimeout({ + projectConfig, + projectEnvironment: input.projectEnvironment, + }); + const readiness: ReadinessPolicy = + input.readiness === "infinite" + ? { mode: "infinite" } + : { + mode: "finite", + timeoutMs: postgresStartupTimeoutMs + LEGACY_NON_DATABASE_READINESS_BUDGET_MS, + }; + const autoExposeOverride = effectiveEnvironmentOverride({ + loaded: input.loadedProjectConfig, + environment: input.projectEnvironment, + path: "api.auto_expose_new_tables", + }); + const autoExposeOverrideValue = + autoExposeOverride === undefined ? undefined : parseGoBoolean(autoExposeOverride); + if (autoExposeOverride !== undefined && autoExposeOverrideValue === undefined) { + return yield* Effect.fail( + invalidLocalStackConfig( + "api.auto_expose_new_tables", + "Use a Go-compatible boolean such as true, false, 1, or 0.", + ), + ); + } + const { autoExposeNewTables, deprecationWarning } = resolveAutoExposeNewTables( + autoExposeOverrideValue ?? projectConfig.api.auto_expose_new_tables, + ); + const diagnostics = diagnosticsFor({ + projectConfig, + rawDocument: input.loadedProjectConfig?.document, + loadedProjectConfig: input.loadedProjectConfig, + projectEnvironment: input.projectEnvironment, + }); + if (diagnostics.blockingPaths.length > 0) { + return yield* Effect.fail( + new LocalStackConfigError({ + detail: `The next local stack does not yet support these explicitly configured settings: ${diagnostics.blockingPaths.join(", ")}.`, + suggestion: + "Remove these settings for now, or use the legacy local stack until their parity slice is available.", + paths: diagnostics.blockingPaths, + }), + ); + } + const versionedConfig = resolveStoredStackLaunch({ + exclude: input.exclude, + mode: input.mode, + runtimeVersions: input.runtimeVersions, + startupMode: input.startupMode, + }); + const coreConfig = yield* Effect.try({ + try: () => + resolveCoreStackConfig({ + loadedProjectConfig: input.loadedProjectConfig, + projectConfig, + rawDocument: input.loadedProjectConfig?.document, + projectEnvironment: input.projectEnvironment, + exclude: input.exclude, + base: versionedConfig, + }), + catch: (cause) => + cause instanceof LocalStackConfigError + ? cause + : new LocalStackConfigError({ + detail: "Invalid local stack configuration.", + suggestion: "Review the configured service topology and port values.", + paths: [], + }), + }); + const configDir = + input.loadedProjectConfig === null + ? join(input.projectPaths.projectRoot, "supabase") + : dirname(input.loadedProjectConfig.path); + const translatedAuth = yield* translateAuthStackConfig({ + projectConfig, + loadedProjectConfig: input.loadedProjectConfig, + projectEnvironment: input.projectEnvironment, + configDir, + authEnabled: coreConfig.auth !== false, + }); + const functionsBundle = + coreConfig.edgeRuntime === false + ? undefined + : yield* translateStartFunctionsStackConfig({ + loadedProjectConfig: input.loadedProjectConfig, + projectEnvironment: input.projectEnvironment, + projectRoot: input.projectPaths.projectRoot, + configDir, + envFilePath: join(configDir, "functions", ".env"), + }); + const translatedDatabaseBootstrap = yield* translateDatabaseBootstrapConfig({ + loadedProjectConfig: input.loadedProjectConfig, + projectEnvironment: input.projectEnvironment, + projectRoot: input.projectPaths.projectRoot, + }); + const databaseWarnings = translatedDatabaseBootstrap.warnings.map( + (warning): LocalStackWarning => ({ code: "unmatched-seed-pattern", ...warning }), + ); + const deprecationWarnings: ReadonlyArray = + deprecationWarning === undefined + ? [] + : [ + { + code: "deprecated", + paths: ["api.auto_expose_new_tables"], + message: deprecationWarning, + }, + ]; + const dataPlaneConfig = yield* Effect.try({ + try: () => + resolveDataPlaneStackConfig({ + loadedProjectConfig: input.loadedProjectConfig, + projectConfig, + projectEnvironment: input.projectEnvironment, + configDir: + input.loadedProjectConfig === null + ? join(input.projectPaths.projectRoot, "supabase") + : dirname(input.loadedProjectConfig.path), + base: coreConfig, + }), + catch: (cause) => + cause instanceof DataPlaneStackConfigError + ? cause + : new DataPlaneStackConfigError({ + detail: "Invalid data-plane service configuration.", + suggestion: "Review the configured data-plane service values.", + paths: [], + }), + }); + + return { + stackConfig: { + ...dataPlaneConfig, + projectDir: input.projectPaths.projectRoot, + readiness, + credentials: translatedAuth.credentials, + databaseBootstrap: translatedDatabaseBootstrap.config, + auth: + translatedAuth.auth === false + ? false + : { + ...translatedAuth.auth, + version: versionedConfig.auth === false ? undefined : versionedConfig.auth?.version, + }, + postgres: { + ...dataPlaneConfig.postgres, + autoExposeNewTables, + startupHealthTimeoutMs: postgresStartupTimeoutMs, + }, + }, + functionsBundle, + projectPaths: input.projectPaths, + warnings: [...diagnostics.warnings, ...databaseWarnings, ...deprecationWarnings], + } satisfies ResolvedLocalStackLaunch; +}); + +export const AUTO_EXPOSE_NEW_TABLES_DEPRECATION_WARNING = + "api.auto_expose_new_tables is deprecated and will be removed on 2026-10-30. Remove the field or set it to false to adopt the new default of revoking Data API privileges on new entities in the public schema."; + +export function resolveAutoExposeNewTables(value: boolean | undefined): { + readonly autoExposeNewTables: boolean; + readonly deprecationWarning: string | undefined; +} { + return { + autoExposeNewTables: value ?? false, + deprecationWarning: value === true ? AUTO_EXPOSE_NEW_TABLES_DEPRECATION_WARNING : undefined, + }; +} diff --git a/apps/cli/src/next/config/stack-config.unit.test.ts b/apps/cli/src/next/config/stack-config.unit.test.ts index d60e7d20fa..f949988e64 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -1,63 +1,577 @@ +import { + ProjectConfigSchema, + type LoadedProjectConfig, + type ProjectEnvironment, +} from "@supabase/config"; +import { BunServices } from "@effect/platform-bun"; +import { Effect, Schema } from "effect"; +import * as SmolToml from "smol-toml"; import { describe, expect, it } from "vitest"; -import { toStartStackConfig, withServiceVersions } from "./stack-config.ts"; +import { renderProjectConfigTemplate } from "../../shared/init/project-init.templates.ts"; +import { + AUTO_EXPOSE_NEW_TABLES_DEPRECATION_WARNING, + baseStackConfig, + explicitLocalStackConfigEntries, + resolveAutoExposeNewTables, + resolveLocalStackLaunch, + resolveStoredStackLaunch, +} from "./stack-config.ts"; -describe("toStartStackConfig", () => { - it("uses lazy service startup with the requested runtime mode", () => { - expect(toStartStackConfig([], "auto")).toMatchObject({ - mode: "auto", - startupMode: "lazy", +const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); + +const resolveLocalStackLaunchWithBun = (input: Parameters[0]) => + resolveLocalStackLaunch(input).pipe(Effect.provide(BunServices.layer)); + +function loaded( + document: Record, + options: { + readonly appliedRemote?: string; + readonly remoteOverridePaths?: ReadonlyArray; + } = {}, +): LoadedProjectConfig { + return { + path: "/project/supabase/config.toml", + format: "toml", + config: decodeProjectConfig(document), + document, + appliedRemote: options.appliedRemote, + remoteOverridePaths: options.remoteOverridePaths, + ignoredPaths: [], + }; +} + +function environment(values: Readonly>): ProjectEnvironment { + return { + paths: { + projectRoot: "/project", + supabaseDir: "/project/supabase", + configPath: "/project/supabase/config.toml", + envPath: "/project/supabase/.env", + envLocalPath: "/project/supabase/.env.local", + }, + values, + loadedPaths: [], + sources: {}, + }; +} + +const baseLaunchInput = { + loadedProjectConfig: null, + projectEnvironment: null, + projectPaths: { + projectRoot: "/project", + projectStateRoot: "/project/.supabase", + }, + mode: "auto" as const, + exclude: [], + runtimeVersions: {}, +}; + +describe("resolveAutoExposeNewTables", () => { + it("preserves the presence-sensitive tri-state behavior", () => { + expect(resolveAutoExposeNewTables(undefined)).toEqual({ + autoExposeNewTables: false, + deprecationWarning: undefined, }); - expect(toStartStackConfig([], "docker")).toMatchObject({ - mode: "docker", - startupMode: "lazy", + expect(resolveAutoExposeNewTables(false)).toEqual({ + autoExposeNewTables: false, + deprecationWarning: undefined, }); - expect(toStartStackConfig([], "native")).toMatchObject({ - mode: "native", - startupMode: "lazy", + expect(resolveAutoExposeNewTables(true)).toEqual({ + autoExposeNewTables: true, + deprecationWarning: AUTO_EXPOSE_NEW_TABLES_DEPRECATION_WARNING, }); }); +}); - it("dedupes excluded services when building stack config", () => { - expect(toStartStackConfig(["auth", "auth"], "auto")).toMatchObject({ - mode: "auto", - auth: false, - }); - expect(toStartStackConfig(["auth", "postgrest"], "auto")).toMatchObject({ - mode: "auto", +describe("baseStackConfig", () => { + it("uses lazy service startup with the requested runtime mode", () => { + expect(baseStackConfig([], "auto")).toMatchObject({ mode: "auto", startupMode: "lazy" }); + expect(baseStackConfig([], "docker")).toMatchObject({ mode: "docker", startupMode: "lazy" }); + expect(baseStackConfig([], "native")).toMatchObject({ mode: "native", startupMode: "lazy" }); + }); + + it("deduplicates exclusions and keeps dependent services disabled", () => { + expect(baseStackConfig(["auth", "auth", "storage"], "auto")).toMatchObject({ auth: false, - postgrest: false, + storage: false, + imgproxy: false, }); }); }); -describe("withServiceVersions", () => { - it("injects linked service versions without re-enabling excluded services", () => { +describe("resolveStoredStackLaunch", () => { + it("injects linked versions without re-enabling excluded services", () => { expect( - withServiceVersions(toStartStackConfig([], "auto"), { - postgres: "17.6.1.090", - postgrest: "14.5", - auth: "2.187.0", - storage: "1.39.2", - realtime: "2.78.10", + resolveStoredStackLaunch({ + exclude: ["auth", "storage"], + mode: "auto", + runtimeVersions: { + postgres: "17.6.1.090", + auth: "2.187.0", + storage: "1.39.2", + }, }), ).toMatchObject({ postgres: { version: "17.6.1.090" }, - postgrest: { version: "14.5" }, - auth: { version: "2.187.0" }, - storage: { version: "1.39.2" }, - realtime: { version: "2.78.10" }, + auth: false, + storage: false, + }); + }); +}); + +describe("explicitLocalStackConfigEntries", () => { + it("expands dynamic record paths and never includes secret values", () => { + const projectConfig = decodeProjectConfig({ + functions: { + hello: { entrypoint: "./functions/hello/index.ts" }, + }, + auth: { jwt_secret: "do-not-return" }, + }); + const entries = explicitLocalStackConfigEntries({ + projectConfig, + rawDocument: { + functions: { hello: { entrypoint: "./functions/hello/index.ts" } }, + auth: { jwt_secret: "do-not-return" }, + }, }); + expect(entries.map(({ path }) => path)).toContain("functions.hello.entrypoint"); + expect(entries.map(({ path }) => path)).toContain("auth.jwt_secret"); + expect(JSON.stringify(entries)).not.toContain("do-not-return"); + }); + + it("does not classify built-in Auth providers through the custom-provider wildcard", () => { + const projectConfig = decodeProjectConfig({ + auth: { + external: { + github: { enabled: true, client_id: "github-client", secret: "github-secret" }, + }, + }, + }); + const entries = explicitLocalStackConfigEntries({ + projectConfig, + rawDocument: { + auth: { + external: { + github: { enabled: true, client_id: "github-client", secret: "github-secret" }, + }, + }, + }, + }); + + expect(entries.filter(({ path }) => path.startsWith("auth.external.github"))).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: "auth.external.github.enabled", + decision: expect.objectContaining({ _tag: "mapped" }), + }), + ]), + ); expect( - withServiceVersions(toStartStackConfig(["auth", "storage"], "auto"), { - postgres: "17.6.1.090", - auth: "2.187.0", - storage: "1.39.2", + entries.some( + ({ path, decision }) => + path.startsWith("auth.external.github") && decision._tag === "unsupported-blocking", + ), + ).toBe(false); + }); +}); + +describe("resolveLocalStackLaunch", () => { + it("accepts the generated project configuration without treating defaults as opt-ins", async () => { + const document = SmolToml.parse(renderProjectConfigTemplate("generated-project", false)); + const result = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + ...baseLaunchInput, + loadedProjectConfig: loaded(document), }), - ).toMatchObject({ - postgres: { version: "17.6.1.090" }, + ); + + expect(result.stackConfig).toBeDefined(); + }); + + it("maps API and database topology into the stack interface", async () => { + const result = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + ...baseLaunchInput, + loadedProjectConfig: loaded({ + api: { + enabled: true, + port: 6101, + schemas: ["public", "private_api"], + extra_search_path: ["extensions"], + max_rows: 250, + }, + db: { port: 6102 }, + }), + }), + ); + + expect(result.stackConfig).toMatchObject({ + port: 6101, + postgres: { port: 6102 }, + postgrest: { + schemas: ["public", "private_api"], + extraSearchPath: ["extensions"], + maxRows: 250, + }, + }); + }); + + it("applies environment overrides before CLI exclusions", async () => { + const result = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + ...baseLaunchInput, + loadedProjectConfig: loaded({ api: { enabled: false, port: 6101 }, db: { port: 6102 } }), + projectEnvironment: { + paths: { + projectRoot: "/project", + supabaseDir: "/project/supabase", + configPath: "/project/supabase/config.toml", + envPath: "/project/supabase/.env", + envLocalPath: "/project/supabase/.env.local", + }, + values: { + SUPABASE_API_ENABLED: "true", + SUPABASE_API_PORT: "6201", + SUPABASE_DB_PORT: "6202", + }, + loadedPaths: [], + sources: {}, + }, + exclude: ["postgrest"], + }), + ); + + expect(result.stackConfig.port).toBe(6201); + expect(result.stackConfig.postgres?.port).toBe(6202); + expect(result.stackConfig.postgrest).toBe(false); + }); + + it("maps legacy API and Edge Runtime environment bindings with Go parsing", async () => { + const result = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + ...baseLaunchInput, + projectEnvironment: environment({ + SUPABASE_API_SCHEMAS: "public,private_api", + SUPABASE_API_EXTRA_SEARCH_PATH: "public,extensions", + SUPABASE_API_MAX_ROWS: "0x100", + SUPABASE_API_AUTO_EXPOSE_NEW_TABLES: "true", + SUPABASE_EDGE_RUNTIME_POLICY: "oneshot", + }), + }), + ); + + expect(result.stackConfig).toMatchObject({ + postgrest: { + schemas: ["public", "private_api"], + extraSearchPath: ["public", "extensions"], + maxRows: 256, + }, + postgres: { autoExposeNewTables: true }, + edgeRuntime: { policy: "oneshot" }, + }); + expect(result.warnings).toContainEqual( + expect.objectContaining({ + code: "deprecated", + paths: ["api.auto_expose_new_tables"], + }), + ); + }); + + it("keeps selected remote core values ahead of legacy environment bindings", async () => { + const document = { + api: { + schemas: ["remote_api"], + extra_search_path: ["remote_extensions"], + max_rows: 321, + auto_expose_new_tables: false, + }, + edge_runtime: { policy: "per_worker" }, + local_smtp: { enabled: true, port: 6104, smtp_port: 6105, pop3_port: 6106 }, + studio: { api_url: "https://remote.example.test" }, + }; + const result = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + ...baseLaunchInput, + loadedProjectConfig: loaded(document, { + appliedRemote: "preview", + remoteOverridePaths: [ + "api.schemas", + "api.extra_search_path", + "api.max_rows", + "api.auto_expose_new_tables", + "edge_runtime.policy", + "local_smtp.smtp_port", + "local_smtp.pop3_port", + "studio.api_url", + ], + }), + projectEnvironment: environment({ + SUPABASE_API_SCHEMAS: "environment_api", + SUPABASE_API_EXTRA_SEARCH_PATH: "environment_extensions", + SUPABASE_API_MAX_ROWS: "999", + SUPABASE_API_AUTO_EXPOSE_NEW_TABLES: "true", + SUPABASE_EDGE_RUNTIME_POLICY: "invalid-private-value", + SUPABASE_LOCAL_SMTP_SMTP_PORT: "0", + SUPABASE_LOCAL_SMTP_POP3_PORT: "0", + SUPABASE_STUDIO_API_URL: "https://environment.example.test", + }), + }), + ); + + expect(result.stackConfig).toMatchObject({ + postgrest: { + schemas: ["remote_api"], + extraSearchPath: ["remote_extensions"], + maxRows: 321, + }, + postgres: { autoExposeNewTables: false }, + edgeRuntime: { policy: "per_worker" }, + mailpit: { smtpPort: 6105, pop3Port: 6106 }, + studio: { apiUrl: "https://remote.example.test" }, + }); + }); + + it("reports malformed topology overrides by path without retaining their value", async () => { + const exit = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + ...baseLaunchInput, + projectEnvironment: { + paths: { + projectRoot: "/project", + supabaseDir: "/project/supabase", + configPath: "/project/supabase/config.toml", + envPath: "/project/supabase/.env", + envLocalPath: "/project/supabase/.env.local", + }, + values: { SUPABASE_DB_PORT: "private-invalid-value" }, + loadedPaths: [], + sources: {}, + }, + }).pipe(Effect.exit), + ); + + expect(exit._tag).toBe("Failure"); + expect(JSON.stringify(exit)).toContain("db.port"); + expect(JSON.stringify(exit)).not.toContain("private-invalid-value"); + }); + + it("only requests Mailpit protocol publication for explicit host ports", async () => { + const omitted = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + ...baseLaunchInput, + loadedProjectConfig: loaded({ local_smtp: { enabled: true, port: 6104 } }), + }), + ); + const explicit = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + ...baseLaunchInput, + loadedProjectConfig: loaded({ + local_smtp: { enabled: true, port: 6104, smtp_port: 6105, pop3_port: 6106 }, + }), + }), + ); + const disabledFromConfig = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + ...baseLaunchInput, + loadedProjectConfig: loaded({ + local_smtp: { enabled: true, port: 6104, smtp_port: 0, pop3_port: 0 }, + }), + }), + ); + const disabledFromEnvironment = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + ...baseLaunchInput, + loadedProjectConfig: loaded({ + local_smtp: { enabled: true, port: 6104, smtp_port: 6105, pop3_port: 6106 }, + }), + projectEnvironment: environment({ + SUPABASE_LOCAL_SMTP_SMTP_PORT: "0", + SUPABASE_LOCAL_SMTP_POP3_PORT: "0", + }), + }), + ); + + expect(omitted.stackConfig.mailpit).toEqual( + expect.not.objectContaining({ smtpPort: expect.anything(), pop3Port: expect.anything() }), + ); + expect(explicit.stackConfig.mailpit).toEqual( + expect.objectContaining({ port: 6104, smtpPort: 6105, pop3Port: 6106 }), + ); + for (const disabled of [disabledFromConfig, disabledFromEnvironment]) { + expect(disabled.stackConfig.mailpit).toEqual( + expect.not.objectContaining({ smtpPort: expect.anything(), pop3Port: expect.anything() }), + ); + } + }); + + it("composes project config, paths, flags, versions, and finite readiness", async () => { + const result = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + ...baseLaunchInput, + loadedProjectConfig: loaded({ + api: { auto_expose_new_tables: true }, + db: { health_timeout: "2m" }, + experimental: { webhooks: { enabled: true } }, + }), + mode: "docker", + exclude: ["auth"], + runtimeVersions: { postgres: "17.6.1.090" }, + }), + ); + + expect(result.stackConfig).toMatchObject({ + projectDir: "/project", + mode: "docker", auth: false, - storage: false, + postgres: { autoExposeNewTables: true, version: "17.6.1.090" }, }); + expect(result.projectPaths.projectStateRoot).toBe("/project/.supabase"); + expect(result.stackConfig.postgres?.startupHealthTimeoutMs).toBe(120_000); + expect(result.stackConfig.readiness).toEqual({ mode: "finite", timeoutMs: 150_000 }); + expect(result.warnings.map(({ code }) => code)).toEqual([ + "unsupported", + "unmatched-seed-pattern", + "deprecated", + ]); + }); + + it("uses the resolved project environment for the database health timeout", async () => { + const result = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + ...baseLaunchInput, + projectEnvironment: { + paths: { + projectRoot: "/project", + supabaseDir: "/project/supabase", + configPath: "/project/supabase/config.toml", + envPath: "/project/supabase/.env", + envLocalPath: "/project/supabase/.env.local", + }, + values: { SUPABASE_DB_HEALTH_TIMEOUT: "5s" }, + loadedPaths: [], + sources: { SUPABASE_DB_HEALTH_TIMEOUT: "ambient" }, + }, + }), + ); + + expect(result.stackConfig.postgres?.startupHealthTimeoutMs).toBe(5_000); + expect(result.stackConfig.readiness).toEqual({ mode: "finite", timeoutMs: 35_000 }); + }); + + it("supports an explicit infinite debugging policy while retaining startup health", async () => { + const result = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ ...baseLaunchInput, readiness: "infinite" }), + ); + + expect(result.stackConfig.postgres?.startupHealthTimeoutMs).toBe(120_000); + expect(result.stackConfig.readiness).toEqual({ mode: "infinite" }); + }); + + it("fails before stack construction when the health timeout is invalid", async () => { + const exit = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + ...baseLaunchInput, + loadedProjectConfig: loaded({ db: { health_timeout: "-1s" } }), + }).pipe(Effect.exit), + ); + + expect(exit._tag).toBe("Failure"); + }); + + it("fails on explicit blocking fields and reports paths without values", async () => { + const exit = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + ...baseLaunchInput, + loadedProjectConfig: loaded({ + auth: { captcha: { enabled: true, secret: "do-not-leak" } }, + api: { tls: { enabled: true, cert_path: "another-private-value" } }, + db: { migrations: { schema_paths: ["./private-schema.sql"] } }, + storage: { buckets: { images: { objects_path: "third-private-value" } } }, + }), + }).pipe(Effect.exit), + ); + + expect(exit._tag).toBe("Failure"); + expect(JSON.stringify(exit)).toContain("auth.captcha.secret"); + expect(JSON.stringify(exit)).toContain("api.tls.cert_path"); + expect(JSON.stringify(exit)).toContain("db.migrations.schema_paths"); + expect(JSON.stringify(exit)).toContain("storage.buckets.images.objects_path"); + expect(JSON.stringify(exit)).not.toContain("do-not-leak"); + expect(JSON.stringify(exit)).not.toContain("another-private-value"); + expect(JSON.stringify(exit)).not.toContain("third-private-value"); + }); + + it("blocks environment-only unsupported settings without retaining values", async () => { + const exit = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + ...baseLaunchInput, + projectEnvironment: environment({ + SUPABASE_API_TLS_ENABLED: "true", + SUPABASE_DB_MAJOR_VERSION: "private-major-version", + }), + }).pipe(Effect.exit), + ); + + expect(exit._tag).toBe("Failure"); + expect(JSON.stringify(exit)).toContain("api.tls.enabled"); + expect(JSON.stringify(exit)).toContain("db.major_version"); + expect(JSON.stringify(exit)).not.toContain("private-major-version"); + }); + + it("blocks a bare Storage bucket declaration", async () => { + const exit = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + ...baseLaunchInput, + loadedProjectConfig: loaded({ storage: { buckets: { images: {} } } }), + }).pipe(Effect.exit), + ); + + expect(exit._tag).toBe("Failure"); + expect(JSON.stringify(exit)).toContain("storage.buckets.images"); + }); + + it("warns on explicit warning fields using paths only", async () => { + const result = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + ...baseLaunchInput, + loadedProjectConfig: loaded({ + experimental: { s3_secret_key: "do-not-leak" }, + }), + }), + ); + + expect(result.warnings).toEqual([ + expect.objectContaining({ code: "unsupported", paths: ["experimental.s3_secret_key"] }), + expect.objectContaining({ + code: "unmatched-seed-pattern", + paths: ["db.seed.sql_paths"], + }), + ]); + expect(JSON.stringify(result.warnings)).not.toContain("do-not-leak"); + }); + + it("warns for environment-only experimental fields and ignores ordinary inspector config", async () => { + const result = await Effect.runPromise( + resolveLocalStackLaunchWithBun({ + ...baseLaunchInput, + loadedProjectConfig: loaded({ edge_runtime: { inspector_port: 9999 } }), + projectEnvironment: environment({ + SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION: "private-experimental-version", + }), + }), + ); + + expect(result.stackConfig.edgeRuntime).not.toEqual( + expect.objectContaining({ inspectorPort: 9999 }), + ); + expect(result.warnings).toContainEqual( + expect.objectContaining({ + code: "unsupported", + paths: ["experimental.orioledb_version"], + }), + ); + expect(JSON.stringify(result.warnings)).not.toContain("private-experimental-version"); }); }); diff --git a/apps/cli/src/next/config/storage-stack-config.ts b/apps/cli/src/next/config/storage-stack-config.ts new file mode 100644 index 0000000000..96a5a2ff41 --- /dev/null +++ b/apps/cli/src/next/config/storage-stack-config.ts @@ -0,0 +1,81 @@ +import { + parseStorageSizeBytes, + type LoadedProjectConfig, + type ProjectConfig, + type ProjectEnvironment, +} from "@supabase/config"; +import type { StorageConfig } from "@supabase/stack/effect"; +import { + environmentOverride, + invalidDataPlaneConfig, + rawEnvironmentOverride, + resolveBooleanOverride, +} from "./data-plane-stack-config-values.ts"; + +function resolveFileSizeLimit(input: { + readonly loaded: LoadedProjectConfig | null; + readonly configured: string; + readonly environment: ProjectEnvironment | null; +}): string { + const configured = + environmentOverride( + "storage.file_size_limit", + input.configured, + input.environment, + input.loaded, + ) ?? input.configured; + try { + return String(parseStorageSizeBytes(configured)); + } catch { + throw invalidDataPlaneConfig( + "storage.file_size_limit", + "Use a byte count or size such as 50MiB.", + ); + } +} + +export function resolveStorageStackConfig(input: { + readonly loaded: LoadedProjectConfig | null; + readonly config: ProjectConfig["storage"]; + readonly environment: ProjectEnvironment | null; + readonly base: StorageConfig | false | undefined; +}): StorageConfig | false { + const fileSizeLimit = resolveFileSizeLimit({ + loaded: input.loaded, + configured: input.config.file_size_limit, + environment: input.environment, + }); + const s3ProtocolEnabled = resolveBooleanOverride({ + loaded: input.loaded, + environment: input.environment, + configured: input.config.s3_protocol.enabled, + path: "storage.s3_protocol.enabled", + }); + const vectorBucketsEnabled = resolveBooleanOverride({ + loaded: input.loaded, + environment: input.environment, + configured: input.config.vector.enabled, + path: "storage.vector.enabled", + }); + const vectorRuntime = vectorBucketsEnabled + ? { + enabled: rawEnvironmentOverride("VECTOR_ENABLED", "true", input.environment) ?? "true", + provider: + rawEnvironmentOverride("VECTOR_BUCKET_PROVIDER", "pgvector", input.environment) ?? + "pgvector", + migrationsEnabled: + rawEnvironmentOverride("VECTOR_STORE_MIGRATIONS_ENABLED", "true", input.environment) ?? + "true", + databaseUrl: rawEnvironmentOverride("VECTOR_DATABASE_URL", undefined, input.environment), + } + : undefined; + + return input.base === false + ? false + : { + ...input.base, + fileSizeLimit, + s3ProtocolEnabled, + vectorRuntime, + }; +} diff --git a/apps/cli/src/next/config/studio-stack-config.ts b/apps/cli/src/next/config/studio-stack-config.ts new file mode 100644 index 0000000000..3ad3d443d0 --- /dev/null +++ b/apps/cli/src/next/config/studio-stack-config.ts @@ -0,0 +1,18 @@ +import type { LoadedProjectConfig, ProjectConfig, ProjectEnvironment } from "@supabase/config"; +import type { StudioConfig } from "@supabase/stack/effect"; +import { environmentOverride } from "./data-plane-stack-config-values.ts"; + +export function resolveStudioStackConfig(input: { + readonly loaded: LoadedProjectConfig | null; + readonly config: ProjectConfig["studio"]; + readonly environment: ProjectEnvironment | null; + readonly base: StudioConfig | false | undefined; +}): StudioConfig | false { + const openAiApiKey = environmentOverride( + "studio.openai_api_key", + input.config.openai_api_key, + input.environment, + input.loaded, + ); + return input.base === false ? false : { ...input.base, openAiApiKey }; +} diff --git a/apps/cli/src/legacy/shared/legacy-go-duration.ts b/apps/cli/src/shared/config/go-duration.ts similarity index 99% rename from apps/cli/src/legacy/shared/legacy-go-duration.ts rename to apps/cli/src/shared/config/go-duration.ts index 8fbee02b37..c576c8e933 100644 --- a/apps/cli/src/legacy/shared/legacy-go-duration.ts +++ b/apps/cli/src/shared/config/go-duration.ts @@ -2,7 +2,7 @@ * Go `time.Duration` string parsing and formatting, ported from Go's * `src/time/time.go` `time.ParseDuration()` and `Duration.String()`. * - * Several `config.toml` fields decode in `@supabase/config` as the raw + * Shared local-config fields decode in `@supabase/config` as the raw * duration STRING (e.g. `auth.sessions.timebox = "1h"`, * `auth.sms.max_frequency = "5s"`) rather than Go's parsed `time.Duration` * (nanoseconds as `int64`). Go itself re-serializes the PARSED value with diff --git a/apps/cli/src/legacy/shared/legacy-go-duration.unit.test.ts b/apps/cli/src/shared/config/go-duration.unit.test.ts similarity index 99% rename from apps/cli/src/legacy/shared/legacy-go-duration.unit.test.ts rename to apps/cli/src/shared/config/go-duration.unit.test.ts index 551f5518ad..c4fa80f75e 100644 --- a/apps/cli/src/legacy/shared/legacy-go-duration.unit.test.ts +++ b/apps/cli/src/shared/config/go-duration.unit.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { legacyFormatGoDuration, legacyParseGoDuration } from "./legacy-go-duration.ts"; +import { legacyFormatGoDuration, legacyParseGoDuration } from "./go-duration.ts"; describe("legacyParseGoDuration", () => { it("parses a single unit", () => { diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index 792c27fbb8..6a08bab0d9 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -10,6 +10,7 @@ import { StackServiceState, StateManager, StackMetadataNotFoundError, + type FunctionsReloadConfig, type StackInfo, type StackMetadata, type StackState, @@ -614,6 +615,9 @@ export function mockStack( ) { let started = false; let stopped = false; + const functionsConfigurations: FunctionsReloadConfig[] = []; + const functionsReloads: FunctionsReloadConfig[] = []; + const operations: string[] = []; const startDeferred = Deferred.makeUnsafe(); const stopDeferred = Deferred.makeUnsafe(); const stateHistory = [...(opts.stateChanges ?? [])]; @@ -653,6 +657,7 @@ export function mockStack( start: () => Effect.gen(function* () { started = true; + operations.push("start"); if (opts.startError !== undefined) { return yield* Effect.fail(opts.startError as never); } @@ -677,7 +682,16 @@ export function mockStack( startService: () => Effect.void, stopService: () => Effect.void, restartService: () => Effect.void, - reloadFunctions: () => Effect.void, + configureFunctions: (config) => + Effect.sync(() => { + functionsConfigurations.push(config); + operations.push("configure-functions"); + }), + reloadFunctions: (config) => + Effect.sync(() => { + functionsReloads.push(config ?? {}); + operations.push("reload-functions"); + }), reloadEdgeRuntime: () => Effect.void, getState: () => Effect.succeed( @@ -746,6 +760,9 @@ export function mockStack( get stopped() { return stopped; }, + functionsConfigurations, + functionsReloads, + operations, emitStateChange(change: { name: string; status: StackServiceState["status"] }) { stateHistory.push(change); PubSub.publishUnsafe( diff --git a/apps/cli/tests/helpers/running-stack.ts b/apps/cli/tests/helpers/running-stack.ts index 9e7ed2e106..b26d8e9c49 100644 --- a/apps/cli/tests/helpers/running-stack.ts +++ b/apps/cli/tests/helpers/running-stack.ts @@ -153,6 +153,10 @@ function makeStackLayer(opts: { opts.states.some((state) => state.name === name) ? Effect.void : Effect.fail(new ServiceNotFoundError({ name })), + configureFunctions: () => + opts.states.some((state) => state.name === "edge-runtime") + ? Effect.void + : Effect.fail(new ServiceNotFoundError({ name: "edge-runtime" })), reloadFunctions: () => opts.states.some((state) => state.name === "edge-runtime") ? Effect.void diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index e2b9475830..52b70a9f15 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -48,3 +48,4 @@ export { ProjectConfigStore } from "./project-config.service.ts"; export { PROJECT_CONFIG_SCHEMA_URL } from "./schema-metadata.ts"; export { KONG_LOCAL_CA_CERT } from "./tls.ts"; export { ENV_CAPTURE_REGEX } from "./lib/env.ts"; +export { InvalidStorageSizeError, parseStorageSizeBytes } from "./storage-size.ts"; diff --git a/packages/config/src/io.ts b/packages/config/src/io.ts index ae427d5f67..8208039dd3 100644 --- a/packages/config/src/io.ts +++ b/packages/config/src/io.ts @@ -35,6 +35,14 @@ export interface LoadedProjectConfig { * `undefined` when no `projectRef` was requested or none matched. */ readonly appliedRemote?: string; + /** + * Config paths explicitly supplied by the applied remote override. Go applies + * these with `viper.Set`, so they take precedence over `SUPABASE_*` + * environment bindings. Paths, rather than values, are retained here so + * downstream presence and precedence checks cannot accidentally expose + * secrets. + */ + readonly remoteOverridePaths?: ReadonlyArray; /** * The top-level `auth.external.{linkedin,slack}` sub-objects that were stripped from * {@link document} before it was returned (provider id → the removed object), keyed by @@ -273,6 +281,16 @@ const checkRemoteProjectIdFormat = Effect.fnUntraced(function* (remotes: Record< * `remotes` subtree) is used only for {@link checkRemoteProjectIdFormat} — see * its doc comment for why that check needs the resolved value instead. */ +interface AppliedRemoteOverride { + readonly document: Record; + readonly appliedRemote: string | undefined; + readonly remoteOverridePaths: ReadonlyArray; +} + +function withoutAppliedRemote(document: Record): AppliedRemoteOverride { + return { document, appliedRemote: undefined, remoteOverridePaths: [] }; +} + const applyRemoteOverride = Effect.fnUntraced(function* ( rawDocument: Record, interpolatedRemotes: Record | undefined, @@ -281,7 +299,7 @@ const applyRemoteOverride = Effect.fnUntraced(function* ( ) { const remotes = rawDocument["remotes"]; if (!isObject(remotes)) { - return { document: rawDocument, appliedRemote: undefined as string | undefined }; + return withoutAppliedRemote(rawDocument); } if (goViperCompat) { yield* checkDuplicateRemoteProjectIds(remotes); @@ -293,7 +311,7 @@ const applyRemoteOverride = Effect.fnUntraced(function* ( return projectRef !== undefined && projectId === projectRef; })?.[0]; if (name === undefined) { - return { document: rawDocument, appliedRemote: undefined as string | undefined }; + return withoutAppliedRemote(rawDocument); } const remoteSubtree = remotes[name]; let merged = isObject(remoteSubtree) @@ -303,9 +321,26 @@ const applyRemoteOverride = Effect.fnUntraced(function* ( merged = withDbSeedDisabled(merged); } delete merged["remotes"]; - return { document: merged, appliedRemote: name }; + const remoteOverridePaths = isObject(remoteSubtree) ? collectConfiguredPaths(remoteSubtree) : []; + return { + document: merged, + appliedRemote: name, + remoteOverridePaths: remoteSetsDbSeedEnabled(isObject(remoteSubtree) ? remoteSubtree : {}) + ? remoteOverridePaths + : [...remoteOverridePaths, "db.seed.enabled"], + } satisfies AppliedRemoteOverride; }); +function collectConfiguredPaths( + value: Readonly>, + prefix = "", +): ReadonlyArray { + return Object.entries(value).flatMap(([key, child]) => { + const path = prefix === "" ? key : `${prefix}.${key}`; + return isObject(child) ? [path, ...collectConfiguredPaths(child, path)] : [path]; + }); +} + function isEqualValue(left: unknown, right: unknown): boolean { if (Array.isArray(left) && Array.isArray(right)) { if (left.length !== right.length) { @@ -748,6 +783,7 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( // checks only run when `goViperCompat` is set — see `applyRemoteOverride`. let documentForDecode: unknown = normalized; let appliedRemote: string | undefined; + let remoteOverridePaths: ReadonlyArray = []; if (isObject(normalized)) { const resolved = yield* applyRemoteOverride( normalized, @@ -757,6 +793,7 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( ); documentForDecode = resolved.document; appliedRemote = resolved.appliedRemote; + remoteOverridePaths = resolved.remoteOverridePaths; } // The merge above ran on the raw document, so any `env(...)` reference in @@ -807,6 +844,7 @@ export const loadProjectConfigFile = Effect.fnUntraced(function* ( ignoredPaths: [], document: isObject(normalizedForDecode) ? normalizedForDecode : undefined, appliedRemote, + remoteOverridePaths, removedDeprecatedExternalProviders: removedProviders, } satisfies LoadedProjectConfig; }); diff --git a/packages/config/src/io.unit.test.ts b/packages/config/src/io.unit.test.ts index b0d75af07b..00b6a1a999 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -1417,6 +1417,9 @@ enabled = false try { const loaded = await runConfigEffect(loadProjectConfig(cwd, { projectRef: PREVIEW_REF })); expect(loaded!.appliedRemote).toBe("preview"); + expect(loaded!.remoteOverridePaths).toEqual( + expect.arrayContaining(["project_id", "api", "api.schemas", "api.max_rows"]), + ); // remote block's project_id overrides the base expect(loaded!.config.project_id).toBe(PREVIEW_REF); // remote scalar wins diff --git a/packages/config/src/storage-size.ts b/packages/config/src/storage-size.ts new file mode 100644 index 0000000000..f99dca669e --- /dev/null +++ b/packages/config/src/storage-size.ts @@ -0,0 +1,54 @@ +export class InvalidStorageSizeError extends Error { + constructor() { + super("invalid size"); + this.name = "InvalidStorageSizeError"; + } +} + +const multipliers: Readonly> = { + k: 1024, + m: 1024 ** 2, + g: 1024 ** 3, + t: 1024 ** 4, + p: 1024 ** 5, +}; + +function invalidSize(): InvalidStorageSizeError { + return new InvalidStorageSizeError(); +} + +/** Parses the Docker/Go RAM-size grammar used by local Storage configuration. */ +export function parseStorageSizeBytes(input: string): number { + let separator = -1; + for (let index = 0; index < input.length; index += 1) { + const character = input[index]; + if (character !== undefined && "0123456789. ".includes(character)) separator = index; + } + if (separator === -1) throw invalidSize(); + + const numeric = + input[separator] === " " ? input.slice(0, separator) : input.slice(0, separator + 1); + let suffix = input.slice(separator + 1); + if ( + !/^[+-]?(?:\d(?:_?\d)*(?:\.(?:\d(?:_?\d)*)?)?|\.\d(?:_?\d)*)([eE][+-]?\d(?:_?\d)*)?$/.test( + numeric, + ) + ) { + throw invalidSize(); + } + const size = Number.parseFloat(numeric.replaceAll("_", "")); + if (!Number.isFinite(size) || size < 0) throw invalidSize(); + if (suffix.length === 0) return Math.trunc(size); + if (suffix.length > 3) throw invalidSize(); + + suffix = suffix.toLowerCase(); + if (suffix[0] === "b") { + if (suffix.length !== 1) throw invalidSize(); + return Math.trunc(size); + } + const multiplier = multipliers[suffix[0] ?? ""]; + if (multiplier === undefined) throw invalidSize(); + if (suffix.length === 2 && suffix[1] !== "b") throw invalidSize(); + if (suffix.length === 3 && suffix.slice(1) !== "ib") throw invalidSize(); + return Math.trunc(size * multiplier); +} diff --git a/packages/config/src/storage-size.unit.test.ts b/packages/config/src/storage-size.unit.test.ts new file mode 100644 index 0000000000..4f2143f5a6 --- /dev/null +++ b/packages/config/src/storage-size.unit.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { InvalidStorageSizeError, parseStorageSizeBytes } from "./storage-size.ts"; + +describe("parseStorageSizeBytes", () => { + it.each([ + ["5000000", 5_000_000], + ["5MiB", 5_242_880], + ["5GB", 5_368_709_120], + ])("parses %s", (input, expected) => { + expect(parseStorageSizeBytes(input)).toBe(expected); + }); + + it("does not include the input in parse errors", () => { + const privateValue = "private-invalid-size"; + try { + parseStorageSizeBytes(privateValue); + throw new Error("expected parser failure"); + } catch (error) { + expect(error).toBeInstanceOf(InvalidStorageSizeError); + expect(JSON.stringify(error)).not.toContain(privateValue); + } + }); +}); diff --git a/packages/config/src/storage.ts b/packages/config/src/storage.ts index 67d6ec0c06..be933c4af9 100644 --- a/packages/config/src/storage.ts +++ b/packages/config/src/storage.ts @@ -37,7 +37,7 @@ const defaultVectorBuckets = {}; * byte count (`5000000`), matching Go's `sizeInBytes` decoder * (apps/cli-go/pkg/config/config_test.go:TestFileSizeLimitConfigParsing). A * numeric value is normalized to its decimal string so the decoded type stays a - * `string` for all consumers (`ramInBytes` parses either form identically). + * `string` for all consumers (`parseStorageSizeBytes` parses either form identically). */ const fileSizeLimit = Schema.Union([Schema.String, Schema.Number]).pipe( Schema.decodeTo(Schema.String, { diff --git a/packages/process-compose/package.json b/packages/process-compose/package.json index 9e9dd92cc9..261f34498d 100644 --- a/packages/process-compose/package.json +++ b/packages/process-compose/package.json @@ -40,8 +40,7 @@ "oxlint-tsgolint" ], "ignoreBinaries": [ - "nx", - "taskkill" + "nx" ] } } diff --git a/packages/stack/README.md b/packages/stack/README.md index ee5285f8c2..961cb0af7a 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -41,7 +41,12 @@ import { createStack } from "@supabase/stack"; import { createClient } from "@supabase/supabase-js"; const stack = await createStack({ - jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + credentials: { + signing: { + _tag: "SymmetricJwtSecret", + secret: "super-secret-jwt-token-with-at-least-32-characters-long", + }, + }, postgres: { dataDir: "./supabase-data" }, }); @@ -60,7 +65,12 @@ await stack.dispose(); ```typescript { await using stack = await createStack({ - jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + credentials: { + signing: { + _tag: "SymmetricJwtSecret", + secret: "super-secret-jwt-token-with-at-least-32-characters-long", + }, + }, postgres: { dataDir: "./supabase-data" }, }); await stack.start(); @@ -80,7 +90,9 @@ await stack.dispose(); | ---------------- | -------------------------------- | -------- | --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `mode` | `"native" \| "auto" \| "docker"` | No | `"auto"` | Resolution mode. `"native"` requires native binaries, `"auto"` tries native first and falls back to Docker, and `"docker"` uses Docker images for all services. | | `startupMode` | `"eager" \| "lazy"` | No | `"eager"` | In lazy mode, proxied HTTP services start on first use. Direct listeners and Realtime start with the stack. | -| `jwtSecret` | `string` | No | | Secret for JWT signing (min 32 characters). Defaults to a well-known dev secret | +| `readiness` | finite or infinite policy | No | `120s` | Stack-wide readiness deadline. Per-call readiness options take precedence. | +| `credentials` | `LocalCredentials` | No | dev keys | Signing material, opaque client keys, and legacy role keys. Signing can use a symmetric secret or asymmetric JWK keys. | +| `jwtSecret` | `string` | No | | Deprecated symmetric-secret shortcut; prefer `credentials.signing`. | | `port` | `number` | No | | API proxy port (auto-allocated if omitted) | | `publishableKey` | `string` | No | | Custom opaque publishable key | | `secretKey` | `string` | No | | Custom opaque secret key | @@ -89,11 +101,13 @@ await stack.dispose(); Optional. When omitted, uses all defaults (ephemeral temp data directory, auto-allocated port). -| Field | Type | Required | Description | -| --------- | -------- | -------- | ------------------------------------------------------------------------------------------- | -| `dataDir` | `string` | No | Directory for Postgres data (PGDATA). Ephemeral temp dir if omitted (cleaned up on dispose) | -| `port` | `number` | No | Postgres port (auto-allocated if omitted) | -| `version` | `string` | No | Override the current pinned Postgres version | +| Field | Type | Required | Description | +| ------------------------ | --------- | -------- | --------------------------------------------------------------------------------------------------------------------- | +| `dataDir` | `string` | No | Directory for Postgres data (PGDATA). Ephemeral temp dir if omitted (cleaned up on dispose) | +| `port` | `number` | No | Postgres port (auto-allocated if omitted) | +| `version` | `string` | No | Override the current pinned Postgres version | +| `autoExposeNewTables` | `boolean` | No | Whether bootstrap SQL preserves default Data API grants | +| `startupHealthTimeoutMs` | `number` | No | Startup probe scheduling budget; does not relax liveness, and the final probe may finish after this scheduling budget | ### `postgrest` @@ -110,19 +124,31 @@ Optional. Omit to include with defaults, set to `false` to exclude. Optional. Omit to include with defaults, set to `false` to exclude. -| Field | Type | Default | Description | -| ------------- | -------- | -------------------------- | ---------------------------------- | -| `port` | `number` | auto | Auth service port | -| `siteUrl` | `string` | `http://localhost:3000` | Auth redirect URL (your app's URL) | -| `jwtExpiry` | `number` | `3600` | JWT expiry in seconds | -| `externalUrl` | `string` | `http://127.0.0.1:${port}` | Auth external URL | -| `version` | `string` | current pinned version | Auth version override | +Auth configuration includes service URLs, redirect allow-lists, token expiry and issuer, signup +and password policy, email/custom SMTP settings, SMS and its selected provider, external OAuth +providers, and Auth hooks. Secret-bearing values are passed directly to the runtime and are never +included in configuration diagnostics. `externalUrl` defaults to the public API URL with +`/auth/v1`; `jwtIssuer` defaults to that same value. + +When `credentials.signing` contains `AsymmetricJwtKeys`, Auth signs with the first RS256 or ES256 +private JWK and receives the complete key array through `GOTRUE_JWT_KEYS`. The stack publishes only +the public fields through its internal JWKS representation. `legacySecret` remains required in +that mode for services that still verify HS256 tokens. + +The resolved stack's JWKS string is internal verifier material, not a public API. Symmetric mode +contains the shared `oct` secret and must never be exposed, persisted, or logged; asymmetric mode +contains public fields only. ### Full config example ```typescript const stack = await createStack({ - jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", + credentials: { + signing: { + _tag: "SymmetricJwtSecret", + secret: "super-secret-jwt-token-with-at-least-32-characters-long", + }, + }, port: 54321, postgres: { port: 54322, dataDir: "/tmp/data" }, postgrest: { schemas: ["public", "custom"], maxRows: 500 }, diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index 54558ffa95..318ee2b4e0 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -39,18 +39,28 @@ can use the same lifecycle calls against an in-process stack or a detached daemo ## Configuration and roots `StackConfig` is an in-memory library input, not the project configuration-file schema. Its -top-level fields choose runtime mode, startup mode, cache/runtime roots, API keys, JWT secret, a -resolved Edge Functions bundle, and per-service configuration. `false` disables an optional +top-level fields choose runtime mode, startup mode, cache/runtime roots, local credentials, +a resolved Edge Functions bundle, and per-service configuration. `false` disables an optional service. `StackConfigResolver.resolveConfig()`: 1. chooses cache, durable stack, runtime, and project roots; 2. allocates every required port through one port allocator; -3. creates development JWTs and opaque publishable/secret keys; +3. resolves `LocalCredentials`, including symmetric or asymmetric signing material, development + role JWTs, opaque publishable/secret keys, and an internal verifier set; 4. applies per-service defaults and current `DEFAULT_VERSIONS`; 5. records auto-managed paths for scoped cleanup. +Project-file translation remains outside this package. The CLI's data-plane launch module resolves +legacy environment overrides and then supplies typed Realtime, Storage, Analytics, Studio, and +Pooler inputs through `StackConfig`. Storage sizes are normalized by the config package's canonical +parser before entering the stack, keeping `StackConfig` focused on runtime-ready values. +Factories consume the resulting values directly: Realtime selects its IP transport, Storage adds +vector-bucket environment only when enabled, Analytics mounts BigQuery credentials, and Studio +receives its optional OpenAI key. Credential contents and configured path values are never included +in validation diagnostics. + Readiness policy is part of the resolved configuration. The package default is a finite three-minute deadline; callers can choose a different finite deadline or explicit infinite waiting. Per-call `ReadyOptions` take precedence over the stack policy, while `inherit` delegates to the stack @@ -59,6 +69,11 @@ reload, and explicit readiness waits. A finite deadline fails with `StackReadine the same scoped cleanup used by disposal. Promise and remote Adapters pass `ReadyOptions` through to that Implementation instead of layering a second timeout rule around it. +PostgreSQL also accepts a startup-health scheduling budget independently of stack readiness. +The native and Docker factories translate that duration into their own probe cadence and cap their +initial delay accordingly; the post-healthy liveness threshold is unchanged. The final failing +probe can finish after the scheduling budget because probe execution has its own timeout. + The current zero-config stack enables PostgreSQL, PostgREST, Auth, and Edge Runtime. Realtime, Storage, imgproxy, Mailpit, Postgres Meta, Studio, Analytics, Vector, and Supavisor are enabled only when their corresponding configuration object is present. In `native` mode, Edge Runtime is also @@ -124,6 +139,18 @@ PostgreSQL. Configuration validation prevents unsupported combinations, including imgproxy without Storage, Vector without Analytics, and Studio without Postgres Meta. +Auth project configuration is translated by the CLI Adapter into the stack-owned `AuthConfig` +domain model. The Auth factory owns GoTrue environment generation for redirects, signup and +password policy, email and SMTP, SMS providers, external OAuth providers, hooks, token expiry, and +signing keys. Secret values remain runtime inputs only: configuration failures identify field paths +without embedding values. Email template content paths remain outside this contract until the +stack owns a template-serving route. + +`ResolvedLocalCredentials.jwks` is internal runtime material. In symmetric mode its `oct` key is +the shared secret, so it must never be exposed, persisted, or logged. In asymmetric mode the +internal verifier set strips private fields. GoTrue separately receives the validated asymmetric +signing array through `GOTRUE_JWT_KEYS`; any public JWKS endpoint must use only its public fields. + ## Lifecycle ownership The local Implementation is `LocalStack`. Its scoped layer owns one lifecycle: diff --git a/packages/stack/package.json b/packages/stack/package.json index d8c3f695d8..e8460e1f06 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -56,8 +56,7 @@ "oxlint-tsgolint" ], "ignoreBinaries": [ - "nx", - "ps" + "nx" ] } } diff --git a/packages/stack/src/AuthConfig.ts b/packages/stack/src/AuthConfig.ts new file mode 100644 index 0000000000..8ab234ef70 --- /dev/null +++ b/packages/stack/src/AuthConfig.ts @@ -0,0 +1,134 @@ +import type { LocalJwtSigningMaterial } from "./LocalCredentials.ts"; + +export type PasswordRequirements = + | "" + | "letters_digits" + | "lower_upper_letters_digits" + | "lower_upper_letters_digits_symbols"; + +export interface AuthEmailConfig { + readonly enableSignup: boolean; + readonly doubleConfirmChanges: boolean; + readonly enableConfirmations: boolean; + readonly securePasswordChange: boolean; + readonly maxFrequency: string; + readonly otpLength: number; + readonly otpExpiry: number; + readonly smtp?: { + readonly host: string; + readonly port: number; + readonly user: string; + readonly pass: string; + readonly adminEmail: string; + readonly senderName?: string; + }; +} + +export interface AuthSmsConfig { + readonly enableSignup: boolean; + readonly enableConfirmations: boolean; + readonly template: string; + readonly maxFrequency: string; + readonly testOtp?: Readonly>; + readonly provider?: + | { + readonly _tag: "twilio"; + readonly accountSid: string; + readonly messageServiceSid: string; + readonly authToken: string; + } + | { + readonly _tag: "twilio-verify"; + readonly accountSid: string; + readonly messageServiceSid: string; + readonly authToken: string; + } + | { + readonly _tag: "messagebird"; + readonly originator: string; + readonly accessKey: string; + } + | { + readonly _tag: "textlocal"; + readonly sender: string; + readonly apiKey: string; + } + | { + readonly _tag: "vonage"; + readonly from: string; + readonly apiKey: string; + readonly apiSecret: string; + }; +} + +export interface AuthExternalProviderConfig { + readonly enabled: boolean; + readonly clientId: string; + readonly secret?: string; + readonly url: string; + readonly redirectUri?: string; + readonly skipNonceCheck: boolean; + readonly emailOptional: boolean; +} + +export interface AuthHookConfig { + readonly enabled: boolean; + readonly uri?: string; + readonly secrets?: string; +} + +export interface AuthRuntimeConfig { + readonly port?: number; + readonly siteUrl?: string; + readonly additionalRedirectUrls?: ReadonlyArray; + readonly jwtExpiry?: number; + readonly jwtIssuer?: string; + readonly externalUrl?: string; + readonly enableSignup?: boolean; + readonly enableAnonymousSignIns?: boolean; + readonly enableRefreshTokenRotation?: boolean; + readonly refreshTokenReuseInterval?: number; + readonly enableManualLinking?: boolean; + readonly minimumPasswordLength?: number; + readonly passwordRequirements?: PasswordRequirements; + readonly email?: AuthEmailConfig; + readonly sms?: AuthSmsConfig; + readonly externalProviders?: Readonly>; + readonly hooks?: Readonly>; + readonly version?: string; +} + +export interface ResolvedAuthRuntimeConfig { + readonly port: number; + readonly siteUrl: string; + readonly additionalRedirectUrls: ReadonlyArray; + readonly jwtExpiry: number; + readonly jwtIssuer: string; + readonly externalUrl: string; + readonly enableSignup: boolean; + readonly enableAnonymousSignIns: boolean; + readonly enableRefreshTokenRotation: boolean; + readonly refreshTokenReuseInterval: number; + readonly enableManualLinking: boolean; + readonly minimumPasswordLength: number; + readonly passwordRequirements: PasswordRequirements; + readonly email: AuthEmailConfig; + readonly sms: AuthSmsConfig; + readonly externalProviders: Readonly>; + readonly hooks: Readonly>; + readonly version: string; +} + +export interface AuthEnvironmentInput { + readonly config: ResolvedAuthRuntimeConfig; + readonly signing: LocalJwtSigningMaterial; + readonly jwtSecret: string; + readonly dbHost: string; + readonly dbPort: number; + readonly smtpFallback?: { + readonly host: string; + readonly port: number; + readonly adminEmail: string; + readonly senderName: string; + }; +} diff --git a/packages/stack/src/DaemonServer.integration.test.ts b/packages/stack/src/DaemonServer.integration.test.ts index d7d8664210..32c5523f07 100644 --- a/packages/stack/src/DaemonServer.integration.test.ts +++ b/packages/stack/src/DaemonServer.integration.test.ts @@ -58,6 +58,7 @@ const MOCK_LOGS: ReadonlyArray = [ function mockStack(options: { readonly startTimeoutMs?: number } = {}) { let stopped = false; const serviceCalls: string[] = []; + const functionConfigurations: FunctionsReloadConfig[] = []; const functionReloads: FunctionsReloadConfig[] = []; const layer = Layer.succeed(Stack, { @@ -98,6 +99,11 @@ function mockStack(options: { readonly startTimeoutMs?: number } = {}) { : Effect.sync(() => { serviceCalls.push(`restart:${name}`); }), + configureFunctions: (config) => + Effect.sync(() => { + functionConfigurations.push(config); + serviceCalls.push("configure-functions"); + }), reloadFunctions: (config) => Effect.sync(() => { functionReloads.push(config ?? {}); @@ -145,6 +151,7 @@ function mockStack(options: { readonly startTimeoutMs?: number } = {}) { return stopped; }, serviceCalls, + functionConfigurations, functionReloads, }; } @@ -395,6 +402,30 @@ describe("DaemonServer", () => { expect(mock.functionReloads).toContainEqual({ functions: functionsBundle }); }); + test("POST /functions/configure forwards without reloading Edge Runtime", async () => { + const reloadCount = mock.functionReloads.length; + const res = await fetch(`${url}/functions/configure`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ functions: functionsBundle }), + }); + + expect(res.status).toBe(200); + expect(mock.functionConfigurations).toContainEqual({ functions: functionsBundle }); + expect(mock.functionReloads).toHaveLength(reloadCount); + }); + + test("configure validation identifies the configure operation", async () => { + const res = await fetch(`${url}/functions/configure`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ functions: { functions: [] } }), + }); + + expect(res.status).toBe(400); + expect(await res.text()).toContain("Invalid Edge Functions configure payload"); + }); + test("reload validation never renders resolved environment values", async () => { const secret = "must-not-appear-in-errors"; const res = await fetch(`${url}/functions/reload`, { diff --git a/packages/stack/src/DaemonServer.ts b/packages/stack/src/DaemonServer.ts index 4fb874f8a5..a489ef3ef7 100644 --- a/packages/stack/src/DaemonServer.ts +++ b/packages/stack/src/DaemonServer.ts @@ -8,7 +8,7 @@ import { } from "effect/unstable/http"; import * as Sse from "effect/unstable/encoding/Sse"; import type { DaemonErrorResponse } from "./DaemonProtocol.ts"; -import { FunctionsReloadConfigSchema } from "./functions.ts"; +import { FunctionsConfigureConfigSchema, FunctionsReloadConfigSchema } from "./functions.ts"; import { EdgeRuntimeReloadConfigSchema, Stack } from "./Stack.ts"; import { ReadyOptionsSchema } from "./StackConfig.ts"; @@ -52,9 +52,14 @@ export class DaemonServer extends Context.Service< ); const buildErrorResponse = (detail: string) => errorResponse({ code: "STACK_BUILD_ERROR", error: detail }, 500); - const invalidReloadPayloadResponse = () => + const invalidFunctionsPayloadResponse = (operation: "configure" | "reload") => HttpServerResponse.jsonUnsafe( - { error: "Invalid Edge Functions reload payload" }, + { error: `Invalid Edge Functions ${operation} payload` }, + { status: 400 }, + ); + const invalidEdgeRuntimeReloadPayloadResponse = () => + HttpServerResponse.jsonUnsafe( + { error: "Invalid Edge Runtime reload payload" }, { status: 400 }, ); const readinessTimeoutResponse = (target: string, timeoutMs: number, detail: string) => @@ -317,6 +322,27 @@ export class DaemonServer extends Context.Service< ), ), + HttpRouter.route( + "POST", + "/functions/configure", + Effect.gen(function* () { + const body = yield* HttpServerRequest.schemaBodyJson(FunctionsConfigureConfigSchema); + yield* stack.configureFunctions(body); + return HttpServerResponse.jsonUnsafe({ ok: true }); + }).pipe( + Effect.catchTags({ + SchemaError: () => Effect.succeed(invalidFunctionsPayloadResponse("configure")), + HttpServerError: () => Effect.succeed(invalidFunctionsPayloadResponse("configure")), + }), + Effect.catchTag("ServiceNotFoundError", (e) => + Effect.succeed(notFoundResponse(e.name)), + ), + Effect.catchTag("StackBuildError", (e) => + Effect.succeed(buildErrorResponse(e.detail)), + ), + ), + ), + HttpRouter.route( "POST", "/functions/reload", @@ -326,8 +352,8 @@ export class DaemonServer extends Context.Service< return HttpServerResponse.jsonUnsafe({ ok: true }); }).pipe( Effect.catchTags({ - SchemaError: () => Effect.succeed(invalidReloadPayloadResponse()), - HttpServerError: () => Effect.succeed(invalidReloadPayloadResponse()), + SchemaError: () => Effect.succeed(invalidFunctionsPayloadResponse("reload")), + HttpServerError: () => Effect.succeed(invalidFunctionsPayloadResponse("reload")), }), Effect.catchTag("ServiceNotFoundError", (e) => Effect.succeed(notFoundResponse(e.name)), @@ -353,8 +379,8 @@ export class DaemonServer extends Context.Service< return HttpServerResponse.jsonUnsafe({ ok: true }); }).pipe( Effect.catchTags({ - SchemaError: () => Effect.succeed(invalidReloadPayloadResponse()), - HttpServerError: () => Effect.succeed(invalidReloadPayloadResponse()), + SchemaError: () => Effect.succeed(invalidEdgeRuntimeReloadPayloadResponse()), + HttpServerError: () => Effect.succeed(invalidEdgeRuntimeReloadPayloadResponse()), }), Effect.catchTag("ServiceNotFoundError", (e) => Effect.succeed(notFoundResponse(e.name)), diff --git a/packages/stack/src/LocalCredentials.ts b/packages/stack/src/LocalCredentials.ts new file mode 100644 index 0000000000..00eca71d12 --- /dev/null +++ b/packages/stack/src/LocalCredentials.ts @@ -0,0 +1,242 @@ +import { createPrivateKey, createPublicKey, createSign } from "node:crypto"; +import { LocalCredentialsError } from "./errors.ts"; +import { + defaultJwtSecret, + defaultPublishableKey, + defaultSecretKey, + generateJwt, +} from "./JwtGenerator.ts"; + +/** RFC 7517 signing-key fields accepted by the local Auth runtime. */ +export interface LocalJwtSigningKey { + readonly kty: string; + readonly kid?: string; + readonly use?: string; + readonly key_ops?: string[]; + readonly alg?: string; + readonly ext?: boolean; + readonly n?: string; + readonly e?: string; + readonly d?: string; + readonly p?: string; + readonly q?: string; + readonly dp?: string; + readonly dq?: string; + readonly qi?: string; + readonly crv?: string; + readonly x?: string; + readonly y?: string; +} + +export type LocalJwtSigningMaterial = + | { + readonly _tag: "SymmetricJwtSecret"; + readonly secret: string; + } + | { + readonly _tag: "AsymmetricJwtKeys"; + readonly keys: readonly [LocalJwtSigningKey, ...ReadonlyArray]; + /** + * HS256 remains the shared secret for services that have not adopted JWKS verification. + * Auth signs new tokens with `keys[0]` while accepting both asymmetric and HS256 tokens. + */ + readonly legacySecret: string; + }; + +/** Input credentials for one local stack. Secret values are data, never diagnostic context. */ +export interface LocalCredentials { + readonly signing?: LocalJwtSigningMaterial; + readonly publishableKey?: string; + readonly secretKey?: string; + readonly anonKey?: string; + readonly serviceRoleKey?: string; +} + +export interface ResolvedLocalCredentials { + readonly signing: LocalJwtSigningMaterial; + readonly jwtSecret: string; + readonly publishableKey: string; + readonly secretKey: string; + readonly anonKey: string; + readonly serviceRoleKey: string; + /** + * Internal verifier set. In symmetric mode it contains the oct secret and must never be + * exposed, persisted, or logged. Asymmetric mode contains public fields only. + */ + readonly jwks: string; +} + +const JWT_LIFETIME_SECONDS = 60 * 60 * 24 * 365 * 10; + +function base64UrlEncode(input: string): string { + return Buffer.from(input).toString("base64url"); +} + +function base64UrlToBigInt(value: string): bigint { + const hex = Buffer.from(value, "base64url").toString("hex"); + return hex.length === 0 ? 0n : BigInt(`0x${hex}`); +} + +function bigIntToBase64Url(value: bigint): string { + let hex = value.toString(16); + if (hex.length % 2 === 1) hex = `0${hex}`; + return Buffer.from(hex, "hex").toString("base64url"); +} + +function modInverse(a: bigint, m: bigint): bigint { + let [oldR, r] = [a, m]; + let [oldS, s] = [1n, 0n]; + while (r !== 0n) { + const quotient = oldR / r; + [oldR, r] = [r, oldR - quotient * r]; + [oldS, s] = [s, oldS - quotient * s]; + } + return ((oldS % m) + m) % m; +} + +function withRsaCrtParameters(key: LocalJwtSigningKey): LocalJwtSigningKey { + if (key.dp !== undefined && key.dq !== undefined && key.qi !== undefined) return key; + if (key.d === undefined || key.p === undefined || key.q === undefined) return key; + + const d = base64UrlToBigInt(key.d); + const p = base64UrlToBigInt(key.p); + const q = base64UrlToBigInt(key.q); + return { + ...key, + dp: bigIntToBase64Url(d % (p - 1n)), + dq: bigIntToBase64Url(d % (q - 1n)), + qi: bigIntToBase64Url(modInverse(q, p)), + }; +} + +function invalidSigningKey(index: number): LocalCredentialsError { + return new LocalCredentialsError({ + path: `credentials.signing.keys[${index}]`, + detail: "The configured local JWT signing key is invalid or unsupported.", + }); +} + +function validateSharedSecret(secret: string, path: string): void { + if (secret.length < 32) { + throw new LocalCredentialsError({ + path, + detail: "The local JWT shared secret must contain at least 32 characters.", + }); + } +} + +function hasValues(key: LocalJwtSigningKey, fields: ReadonlyArray) { + return fields.every((field) => { + const value = key[field]; + return typeof value === "string" && value.length > 0; + }); +} + +/** Validate algorithms, key types, public components, and the first key's private material. */ +export function validateLocalJwtSigningKeys( + keys: ReadonlyArray, +): asserts keys is readonly [LocalJwtSigningKey, ...ReadonlyArray] { + if (keys.length === 0) throw invalidSigningKey(0); + + for (const [index, key] of keys.entries()) { + const isRsa = key.alg === "RS256" && key.kty === "RSA"; + const isEc = key.alg === "ES256" && key.kty === "EC" && key.crv === "P-256"; + const hasPublicMaterial = isRsa ? hasValues(key, ["n", "e"]) : hasValues(key, ["x", "y"]); + const hasPrivateMaterial = + index !== 0 || (isRsa ? hasValues(key, ["d", "p", "q"]) : hasValues(key, ["d"])); + if ((!isRsa && !isEc) || !hasPublicMaterial || !hasPrivateMaterial) { + throw invalidSigningKey(index); + } + + try { + createPublicKey({ key, format: "jwk" }); + if (index === 0) { + createPrivateKey({ key: isRsa ? withRsaCrtParameters(key) : key, format: "jwk" }); + } + } catch { + throw invalidSigningKey(index); + } + } +} + +function generateAsymmetricJwt(key: LocalJwtSigningKey, role: "anon" | "service_role"): string { + const algorithm = key.alg; + if ( + (algorithm !== "RS256" || key.kty !== "RSA") && + (algorithm !== "ES256" || key.kty !== "EC" || key.crv !== "P-256") + ) { + throw invalidSigningKey(0); + } + + const header = + key.kid === undefined || key.kid.length === 0 + ? { alg: algorithm, typ: "JWT" } + : { alg: algorithm, kid: key.kid, typ: "JWT" }; + const payload = { + iss: "supabase-demo", + role, + exp: Math.floor(Date.now() / 1000) + JWT_LIFETIME_SECONDS, + }; + const data = `${base64UrlEncode(JSON.stringify(header))}.${base64UrlEncode(JSON.stringify(payload))}`; + try { + const privateKey = createPrivateKey({ + key: algorithm === "RS256" ? withRsaCrtParameters(key) : key, + format: "jwk", + }); + const signature = + algorithm === "RS256" + ? createSign("RSA-SHA256").update(data).end().sign(privateKey) + : createSign("sha256") + .update(data) + .end() + .sign({ key: privateKey, dsaEncoding: "ieee-p1363" }); + return `${data}.${signature.toString("base64url")}`; + } catch { + throw invalidSigningKey(0); + } +} + +function publicSigningKey(key: LocalJwtSigningKey): LocalJwtSigningKey { + const { d: _d, p: _p, q: _q, dp: _dp, dq: _dq, qi: _qi, ...publicKey } = key; + return publicKey; +} + +export function authSigningKeysJson(signing: LocalJwtSigningMaterial): string | undefined { + return signing._tag === "AsymmetricJwtKeys" ? JSON.stringify(signing.keys) : undefined; +} + +export function resolveLocalCredentials( + input: LocalCredentials | undefined, +): ResolvedLocalCredentials { + const signing = input?.signing ?? { + _tag: "SymmetricJwtSecret", + secret: defaultJwtSecret, + }; + const jwtSecret = signing._tag === "SymmetricJwtSecret" ? signing.secret : signing.legacySecret; + if (signing._tag === "SymmetricJwtSecret") { + validateSharedSecret(signing.secret, "credentials.signing.secret"); + } else { + validateSharedSecret(signing.legacySecret, "credentials.signing.legacySecret"); + validateLocalJwtSigningKeys(signing.keys); + } + const generateRoleKey = (role: "anon" | "service_role") => + signing._tag === "SymmetricJwtSecret" + ? generateJwt(signing.secret, role) + : generateAsymmetricJwt(signing.keys[0], role); + const jwks = + signing._tag === "SymmetricJwtSecret" + ? JSON.stringify({ + keys: [{ kty: "oct", k: Buffer.from(jwtSecret).toString("base64url") }], + }) + : JSON.stringify({ keys: signing.keys.map(publicSigningKey) }); + + return { + signing, + jwtSecret, + publishableKey: input?.publishableKey ?? defaultPublishableKey, + secretKey: input?.secretKey ?? defaultSecretKey, + anonKey: input?.anonKey ?? generateRoleKey("anon"), + serviceRoleKey: input?.serviceRoleKey ?? generateRoleKey("service_role"), + jwks, + }; +} diff --git a/packages/stack/src/LocalCredentials.unit.test.ts b/packages/stack/src/LocalCredentials.unit.test.ts new file mode 100644 index 0000000000..c4349078f5 --- /dev/null +++ b/packages/stack/src/LocalCredentials.unit.test.ts @@ -0,0 +1,143 @@ +import { createPublicKey, verify } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import { defaultPublishableKey, defaultSecretKey } from "./JwtGenerator.ts"; +import { resolveLocalCredentials } from "./LocalCredentials.ts"; + +const localEs256Key = { + kty: "EC", + kid: "local-auth-test", + use: "sig", + alg: "ES256", + crv: "P-256", + x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", + y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", + d: "dIhR8wywJlqlua4y_yMq2SLhlFXDZJBCvFrY1DCHyVU", +}; + +describe("resolveLocalCredentials", () => { + it("resolves symmetric defaults as one coherent credential set", () => { + const credentials = resolveLocalCredentials(undefined); + + expect(credentials.signing._tag).toBe("SymmetricJwtSecret"); + expect(credentials.publishableKey).toBe(defaultPublishableKey); + expect(credentials.secretKey).toBe(defaultSecretKey); + expect(credentials.anonKey.split(".")).toHaveLength(3); + expect(credentials.serviceRoleKey.split(".")).toHaveLength(3); + expect(JSON.parse(credentials.jwks)).toEqual({ + keys: [expect.objectContaining({ kty: "oct", k: expect.any(String) })], + }); + }); + + it("signs role tokens with the first asymmetric key and publishes only public material", () => { + const credentials = resolveLocalCredentials({ + signing: { + _tag: "AsymmetricJwtKeys", + legacySecret: "legacy-shared-secret-with-at-least-32-characters", + keys: [localEs256Key], + }, + }); + const [headerEncoded, payloadEncoded, signatureEncoded] = credentials.anonKey.split("."); + expect(headerEncoded).toBeDefined(); + expect(payloadEncoded).toBeDefined(); + expect(signatureEncoded).toBeDefined(); + if ( + headerEncoded === undefined || + payloadEncoded === undefined || + signatureEncoded === undefined + ) { + return; + } + + expect(JSON.parse(Buffer.from(headerEncoded, "base64url").toString("utf8"))).toMatchObject({ + alg: "ES256", + kid: "local-auth-test", + }); + expect(JSON.parse(Buffer.from(payloadEncoded, "base64url").toString("utf8"))).toMatchObject({ + role: "anon", + }); + const publicKey = createPublicKey({ key: localEs256Key, format: "jwk" }); + expect( + verify( + "sha256", + Buffer.from(`${headerEncoded}.${payloadEncoded}`), + { key: publicKey, dsaEncoding: "ieee-p1363" }, + Buffer.from(signatureEncoded, "base64url"), + ), + ).toBe(true); + + const publicJwks = JSON.parse(credentials.jwks); + expect(publicJwks.keys[0]).not.toHaveProperty("d"); + expect(publicJwks.keys[0]).not.toHaveProperty("p"); + expect(publicJwks.keys[0]).not.toHaveProperty("q"); + }); + + it("rejects mismatched private keys with path-only typed errors", () => { + expect.assertions(4); + try { + resolveLocalCredentials({ + signing: { + _tag: "AsymmetricJwtKeys", + legacySecret: "legacy-shared-secret-with-at-least-32-characters", + keys: [{ ...localEs256Key, kty: "RSA", d: "do-not-expose-private-key" }], + }, + }); + } catch (error) { + expect(error).toMatchObject({ + _tag: "LocalCredentialsError", + path: "credentials.signing.keys[0]", + }); + expect(JSON.stringify(error)).not.toContain("do-not-expose-private-key"); + expect(JSON.stringify(error)).not.toContain(localEs256Key.x); + expect(JSON.stringify(error)).not.toContain(localEs256Key.y); + } + }); + + it("validates public components on every later verification key", () => { + expect.assertions(1); + try { + resolveLocalCredentials({ + signing: { + _tag: "AsymmetricJwtKeys", + legacySecret: "legacy-shared-secret-with-at-least-32-characters", + keys: [localEs256Key, { ...localEs256Key, kid: "invalid-verifier", x: undefined }], + }, + }); + } catch (error) { + expect(error).toMatchObject({ + _tag: "LocalCredentialsError", + path: "credentials.signing.keys[1]", + }); + } + }); + + it("honors configured opaque and legacy role keys without recomputing them", () => { + const credentials = resolveLocalCredentials({ + publishableKey: "sb_publishable_override", + secretKey: "sb_secret_override", + anonKey: "anon-override", + serviceRoleKey: "service-role-override", + }); + + expect(credentials).toMatchObject({ + publishableKey: "sb_publishable_override", + secretKey: "sb_secret_override", + anonKey: "anon-override", + serviceRoleKey: "service-role-override", + }); + }); + + it("rejects short shared secrets without retaining their value", () => { + expect.assertions(2); + try { + resolveLocalCredentials({ + signing: { _tag: "SymmetricJwtSecret", secret: "short-secret-value" }, + }); + } catch (error) { + expect(error).toMatchObject({ + _tag: "LocalCredentialsError", + path: "credentials.signing.secret", + }); + expect(JSON.stringify(error)).not.toContain("short-secret-value"); + } + }); +}); diff --git a/packages/stack/src/LocalStack.ts b/packages/stack/src/LocalStack.ts index 35ffba9fdf..b667735397 100644 --- a/packages/stack/src/LocalStack.ts +++ b/packages/stack/src/LocalStack.ts @@ -118,8 +118,12 @@ const stackInfoFor = (config: ResolvedStackConfig): StackInfo => { ? {} : { mailpit: `http://127.0.0.1:${config.mailpit.port}`, - mailpit_smtp: `smtp://127.0.0.1:${config.mailpit.smtpPort}`, - mailpit_pop3: `pop3://127.0.0.1:${config.mailpit.pop3Port}`, + ...(config.mailpit.smtpHostPort === false + ? {} + : { mailpit_smtp: `smtp://127.0.0.1:${config.mailpit.smtpHostPort}` }), + ...(config.mailpit.pop3HostPort === false + ? {} + : { mailpit_pop3: `pop3://127.0.0.1:${config.mailpit.pop3HostPort}` }), }), ...(config.pgmeta === false ? {} : { pgmeta: `${apiUrl}/pg` }), ...(config.studio === false ? {} : { studio: `http://127.0.0.1:${config.studio.port}` }), @@ -499,13 +503,26 @@ export const localStackLayer = ( detail: `Prepared graph does not contain enabled service ${service}`, cause, }); + const activationTargetNames = ( + runtime: RuntimeState, + root: ServiceName, + ): ReadonlyArray => + activationTargetsForService(enabledServices, root).map((target) => { + if (target !== "postgres") return target; + if (runtime.graph.startOrder.some((definition) => definition.name === "postgres-seed")) { + return "postgres-seed"; + } + return runtime.graph.startOrder.some((definition) => definition.name === "postgres-init") + ? "postgres-init" + : target; + }); const beginStartTargets = ( root: ServiceName, allowExplicitlyStopped: ReadonlySet, ) => Effect.gen(function* () { const runtime = yield* ensureRuntime; - const targets = activationTargetsForService(enabledServices, root); + const targets = activationTargetNames(runtime, root); const targetClosure = new Set( targets.flatMap((target) => runtime.graph.startOrderFor(target).map((definition) => definition.name), @@ -550,7 +567,7 @@ export const localStackLayer = ( targets, }: { readonly runtime: RuntimeState; - readonly targets: ReadonlyArray; + readonly targets: ReadonlyArray; }) => Effect.gen(function* () { yield* Effect.forEach( @@ -570,7 +587,7 @@ export const localStackLayer = ( const inspectStartedTargets = (root: ServiceName) => Effect.gen(function* () { const runtime = yield* ensureRuntime; - const targets = activationTargetsForService(enabledServices, root); + const targets = activationTargetNames(runtime, root); const states = yield* Effect.forEach(targets, (target) => runtime.orchestrator .getState(target) @@ -705,26 +722,6 @@ export const localStackLayer = ( if (config.startupMode === "lazy") { const readiness: Array> = []; - if ( - runtime.graph.startOrder.some((definition) => definition.name === "postgres-init") - ) { - yield* runtime.orchestrator - .startService("postgres-init", serviceStartOptions) - .pipe( - Effect.catchTag("ServiceNotFoundError", (cause) => - Effect.fail(knownServiceError("postgres-init", cause)), - ), - ); - readiness.push( - runtime.orchestrator - .waitReady("postgres-init") - .pipe( - Effect.catchTag("ServiceNotFoundError", (cause) => - Effect.fail(knownServiceError("postgres-init", cause)), - ), - ), - ); - } for (const service of eagerServices(enabledServices)) { const started = yield* beginStartTargets( service, @@ -797,6 +794,15 @@ export const localStackLayer = ( }).pipe(withLifecycleLock); yield* waitForTargets(started).pipe((effect) => withReadinessPolicy(effect, name)); }).pipe(cleanupOnReadinessFailure), + configureFunctions: (opts) => + Effect.gen(function* () { + yield* requireMutable("configure functions"); + yield* requireKnownService("edge-runtime"); + if (opts.functions !== undefined) { + yield* Ref.set(functionsBundleRef, opts.functions); + } + yield* configureFunctions(config); + }).pipe(withLifecycleLock), reloadFunctions: (opts) => Effect.gen(function* () { const started = yield* Effect.gen(function* () { diff --git a/packages/stack/src/Platform.ts b/packages/stack/src/Platform.ts index 5ed0e87e5e..52ff820e78 100644 --- a/packages/stack/src/Platform.ts +++ b/packages/stack/src/Platform.ts @@ -61,8 +61,12 @@ export const dockerPortMapArgs = ( mappings: ReadonlyArray<{ readonly host: number; readonly container: number; + readonly hostAddress?: string; }>, ): readonly string[] => [ ...dockerHostGatewayArgs(os), - ...mappings.flatMap(({ host, container }) => ["-p", `${host}:${container}`]), + ...mappings.flatMap(({ host, container, hostAddress }) => [ + "-p", + hostAddress === undefined ? `${host}:${container}` : `${hostAddress}:${host}:${container}`, + ]), ]; diff --git a/packages/stack/src/RemoteStack.integration.test.ts b/packages/stack/src/RemoteStack.integration.test.ts index 289d3cf64e..7597ec5584 100644 --- a/packages/stack/src/RemoteStack.integration.test.ts +++ b/packages/stack/src/RemoteStack.integration.test.ts @@ -83,6 +83,7 @@ function mockStack( ) { let stopped = false; const serviceCalls: string[] = []; + const functionConfigurations: FunctionsReloadConfig[] = []; const functionReloads: FunctionsReloadConfig[] = []; const edgeRuntimeReloads: EdgeRuntimeReloadConfig[] = []; const readinessCalls: Array<{ readonly target: string; readonly options?: ReadyOptions }> = []; @@ -132,6 +133,11 @@ function mockStack( : Effect.sync(() => { serviceCalls.push(`restart:${name}`); }), + configureFunctions: (config) => + Effect.sync(() => { + functionConfigurations.push(config); + serviceCalls.push("configure-functions"); + }), reloadFunctions: (config) => Effect.sync(() => { functionReloads.push(config ?? {}); @@ -205,6 +211,7 @@ function mockStack( }, serviceCalls, readinessCalls, + functionConfigurations, functionReloads, edgeRuntimeReloads, }; @@ -496,6 +503,16 @@ describe("RemoteStack integration", () => { expect(mock.functionReloads).toEqual([{ functions: functionsBundle }]); }); + test("configureFunctions transports the bundle without using reload", async () => { + const reloadCount = mock.functionReloads.length; + await clientRuntime.runPromise( + Effect.flatMap(Stack, (stack) => stack.configureFunctions({ functions: functionsBundle })), + ); + + expect(mock.functionConfigurations).toEqual([{ functions: functionsBundle }]); + expect(mock.functionReloads).toHaveLength(reloadCount); + }); + test("reloadEdgeRuntime records the call", async () => { await clientRuntime.runPromise( Effect.flatMap(Stack, (stack) => diff --git a/packages/stack/src/RemoteStack.ts b/packages/stack/src/RemoteStack.ts index 7bbd6fb72a..e508c53faa 100644 --- a/packages/stack/src/RemoteStack.ts +++ b/packages/stack/src/RemoteStack.ts @@ -334,6 +334,21 @@ export const RemoteStack = { }), ), + configureFunctions: (opts) => + withUnixHttpClient( + Effect.gen(function* () { + const response = yield* unixResponse(socketPath, "/functions/configure", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(opts), + }); + yield* expectDaemonOk(response, "edge-runtime").pipe( + Effect.catchTag("ServiceReadyError", (error) => Effect.die(error)), + Effect.catchTag("StackReadinessError", (error) => Effect.die(error)), + ); + }), + ), + reloadFunctions: (opts) => withUnixHttpClient( Effect.gen(function* () { diff --git a/packages/stack/src/Stack.ts b/packages/stack/src/Stack.ts index 9693b08d02..d9682b045d 100644 --- a/packages/stack/src/Stack.ts +++ b/packages/stack/src/Stack.ts @@ -4,6 +4,7 @@ import { Context, Effect, Schema, Stream } from "effect"; import { StackBuildError, StackReadinessError } from "./errors.ts"; import { ResolvedFunctionsBundleSchema, + type FunctionsConfigureConfig, type FunctionsReloadConfig, type ResolvedFunctionsBundle, } from "./functions.ts"; @@ -72,6 +73,10 @@ export class Stack extends Context.Service< void, ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError >; + /** Store Functions inputs without changing Edge Runtime lifecycle state. */ + readonly configureFunctions: ( + opts: FunctionsConfigureConfig, + ) => Effect.Effect; readonly reloadFunctions: ( opts?: FunctionsReloadConfig, ) => Effect.Effect< diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index f471a80955..d17903cd02 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -10,6 +10,7 @@ import { Deferred, Effect, Exit, Fiber, Layer, Stream } from "effect"; import { mockChildProcessSpawner } from "../../process-compose/tests/helpers/mocks.ts"; import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; import { defaultPublishableKey, defaultSecretKey, generateJwt } from "./JwtGenerator.ts"; +import { resolveLocalCredentials } from "./LocalCredentials.ts"; import { functionsRuntimeConfigPath, type ResolvedFunctionsBundle } from "./functions.ts"; import type { AllocatedPorts, PortField, PortLease } from "./PortAllocator.ts"; import { StackServiceActivator } from "./ServiceActivation.ts"; @@ -22,6 +23,9 @@ import { DEFAULT_STACK_READINESS_POLICY, type ResolvedStackConfig } from "./Stac import { DEFAULT_VERSIONS } from "./versions.ts"; const testJwtSecret = "super-secret-jwt-token-with-at-least-32-characters-long"; +const testCredentials = resolveLocalCredentials({ + signing: { _tag: "SymmetricJwtSecret", secret: testJwtSecret }, +}); const defaultPorts: AllocatedPorts = { apiPort: 54321, @@ -52,6 +56,7 @@ const defaultConfig: ResolvedStackConfig = { mode: "native", startupMode: "eager", readiness: DEFAULT_STACK_READINESS_POLICY, + credentials: testCredentials, jwtSecret: testJwtSecret, ports: defaultPorts, apiPort: 54321, @@ -62,6 +67,7 @@ const defaultConfig: ResolvedStackConfig = { autoManagedPaths: [], anonJwt: generateJwt(testJwtSecret, "anon"), serviceRoleJwt: generateJwt(testJwtSecret, "service_role"), + databaseBootstrap: { seedFiles: [] }, postgres: { port: 54322, dataDir: "/tmp/supabase/data", @@ -79,8 +85,34 @@ const defaultConfig: ResolvedStackConfig = { auth: { port: 9999, siteUrl: "http://localhost:3000", + additionalRedirectUrls: ["http://localhost:3000/**"], jwtExpiry: 3600, - externalUrl: "http://127.0.0.1:54321", + jwtIssuer: "http://127.0.0.1:54321/auth/v1", + externalUrl: "http://127.0.0.1:54321/auth/v1", + enableSignup: true, + enableAnonymousSignIns: false, + enableRefreshTokenRotation: true, + refreshTokenReuseInterval: 10, + enableManualLinking: false, + minimumPasswordLength: 6, + passwordRequirements: "", + email: { + enableSignup: true, + doubleConfirmChanges: true, + enableConfirmations: false, + securePasswordChange: false, + maxFrequency: "1s", + otpLength: 6, + otpExpiry: 3600, + }, + sms: { + enableSignup: false, + enableConfirmations: false, + template: "Your code is {{ .Code }}", + maxFrequency: "5s", + }, + externalProviders: {}, + hooks: {}, version: DEFAULT_VERSIONS.auth, }, edgeRuntime: false, @@ -174,6 +206,72 @@ describe("Stack", () => { }).pipe(Effect.provide(layer)); }); + it.live("stores Functions config without activating a lazy Edge Runtime", () => { + const runtimeRoot = mkdtempSync(join(tmpdir(), "supabase-functions-configure-")); + const bundle = functionsBundle(runtimeRoot, "configured-before-start"); + const config = { + ...edgeRuntimeConfig, + runtimeRoot, + startupMode: "lazy", + functions: false, + auth: false, + postgrest: false, + } satisfies ResolvedStackConfig; + const graph = Effect.runSync( + buildGraph([ + { name: "postgres", command: process.execPath, restart: "unless-stopped" }, + { + name: "edge-runtime", + command: process.execPath, + dependencies: [{ service: "postgres", condition: "healthy" }], + restart: "unless-stopped", + }, + ]), + ); + const builderLayer = Layer.succeed(StackBuilder, { + build: () => + Effect.succeed({ + graph, + cleanupTargets: { dockerContainerNames: [] }, + serviceProjection: new Map([ + ["postgres", { visibility: "public" as const }], + ["edge-runtime", { visibility: "public" as const }], + ]), + }), + }); + const resolver = mockBinaryResolver(); + const spawner = mockChildProcessSpawner(); + const layer = localStackLayer(config, noopPortLease(config.ports)).pipe( + Layer.provide(builderLayer), + Layer.provide(StackPreparation.layer.pipe(Layer.provide(resolver.layer))), + Layer.provide(StackMetadataPersistence.noop), + Layer.provide(spawner.layer), + Layer.provide(BunServices.layer), + ); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.configureFunctions({ functions: bundle }); + const configureSpawnCount = spawner.spawned.length; + expect(spawner.spawned.some((record) => record.command === process.execPath)).toBe(false); + + yield* stack.start(); + expect(spawner.spawned).toHaveLength(configureSpawnCount + 1); + expect( + JSON.parse( + yield* Effect.promise(() => readFile(functionsRuntimeConfigPath(runtimeRoot), "utf8")), + ).env.SHARED, + ).toBe("configured-before-start"); + + yield* stack.startService("edge-runtime"); + expect(spawner.spawned).toHaveLength(configureSpawnCount + 2); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.promise(() => rm(runtimeRoot, { recursive: true, force: true }))), + Effect.timeout("5 seconds"), + ); + }); + it.live("preserves the current functions bundle across repeated runtime reloads", () => { const runtimeRoot = mkdtempSync(join(tmpdir(), "supabase-functions-reload-")); const initialBundle = functionsBundle(runtimeRoot, "initial-secret"); @@ -589,7 +687,20 @@ describe("Stack", () => { }); it.live("lazy startup starts direct services without starting HTTP backends", () => { - const { layer, spawner } = setupLayer({ ...defaultConfig, startupMode: "lazy" }); + const config: ResolvedStackConfig = { + ...defaultConfig, + startupMode: "lazy", + databaseBootstrap: { + seedFiles: [ + { + path: "/tmp/supabase-project/supabase/seed.sql", + historyPath: "supabase/seed.sql", + checksum: "a".repeat(64), + }, + ], + }, + }; + const { layer, spawner } = setupLayer(config); return Effect.gen(function* () { const stack = yield* Stack; @@ -605,11 +716,91 @@ describe("Stack", () => { ).toBe(true); expect(spawner.spawned.some((record) => record.command.endsWith("/auth"))).toBe(false); expect(spawner.spawned.some((record) => record.command.endsWith("/postgrest"))).toBe(false); + expect( + spawner.spawned.some((record) => + record.args.some((arg) => + Buffer.from(arg, "base64url").toString().includes("postgres-seed"), + ), + ), + ).toBe(true); + + yield* stack.stop(); + }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); + }); + + it.live("startService postgres reactivates its terminal seed helper", () => { + const config: ResolvedStackConfig = { + ...defaultConfig, + startupMode: "lazy", + databaseBootstrap: { + seedFiles: [ + { + path: "/tmp/supabase-project/supabase/seed.sql", + historyPath: "supabase/seed.sql", + checksum: "a".repeat(64), + }, + ], + }, + }; + const { layer, spawner } = setupLayer(config); + const seedSpawnCount = () => + spawner.spawned.filter((record) => + record.args.some((arg) => + Buffer.from(arg, "base64url").toString().includes("postgres-seed"), + ), + ).length; + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + const initialSeedSpawns = seedSpawnCount(); + expect(initialSeedSpawns).toBeGreaterThan(0); + + yield* stack.stopService("postgres"); + yield* stack.startService("postgres"); + expect(seedSpawnCount()).toBeGreaterThan(initialSeedSpawns); yield* stack.stop(); }).pipe(Effect.provide(layer), Effect.timeout("5 seconds")); }); + it.live( + "lazy Docker postgres activation applies and reactivates its privilege policy", + () => { + const config: ResolvedStackConfig = { + ...defaultConfig, + mode: "docker", + startupMode: "lazy", + postgres: { ...defaultConfig.postgres, autoExposeNewTables: false }, + }; + const { layer, spawner } = setupLayer(config); + const privilegeInitSpawnCount = () => + spawner.spawned.filter((record) => + record.args.some((arg) => { + const definition = Buffer.from(arg, "base64url").toString(); + return ( + definition.includes("postgres-init") && + definition.includes("alter default privileges for role postgres") + ); + }), + ).length; + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + const initialPrivilegeInitSpawns = privilegeInitSpawnCount(); + expect(initialPrivilegeInitSpawns).toBeGreaterThan(0); + + yield* stack.stopService("postgres"); + yield* stack.startService("postgres"); + + expect(privilegeInitSpawnCount()).toBeGreaterThan(initialPrivilegeInitSpawns); + yield* stack.stop(); + }).pipe(Effect.provide(layer), Effect.timeout("10 seconds")); + }, + 10_000, + ); + it.live("lazy activation honors explicitly stopped transitive dependencies", () => { const config: ResolvedStackConfig = { ...defaultConfig, @@ -618,7 +809,7 @@ describe("Stack", () => { storage: { port: defaultPorts.storagePort, dataDir: "/tmp/supabase/storage", - fileSizeLimit: "50MiB", + fileSizeLimit: "52428800", s3ProtocolEnabled: true, version: DEFAULT_VERSIONS.storage, }, @@ -737,8 +928,9 @@ describe("Stack", () => { startupMode: "lazy", mailpit: { port: defaultPorts.mailpitPort, - smtpPort: defaultPorts.mailpitSmtpPort, - pop3Port: defaultPorts.mailpitPop3Port, + smtpTransportPort: defaultPorts.mailpitSmtpPort, + smtpHostPort: false, + pop3HostPort: false, version: DEFAULT_VERSIONS.mailpit, adminEmail: "admin@example.com", senderName: "Admin", diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index 756b7a1119..2d452c8115 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -3,10 +3,13 @@ import type { ResolvedGraph, ServiceDef } from "@supabase/process-compose"; import { Effect, Layer, Context } from "effect"; import { dockerContainerName, type CleanupTargets } from "./CleanupTargets.ts"; import { StackBuildError } from "./errors.ts"; -import { generateJwks } from "./JwtGenerator.ts"; import { detectPlatform, dockerHostAddress } from "./Platform.ts"; import { makeAnalyticsServiceDocker } from "./services/analytics.ts"; import { makeAuthServiceDocker, makeAuthServiceNative } from "./services/auth.ts"; +import { + makeDatabaseSeedService, + type DatabaseBootstrapRuntime, +} from "./services/database-bootstrap.ts"; import { makeEdgeRuntimeServiceDocker, makeEdgeRuntimeServiceNative, @@ -15,7 +18,10 @@ import { makeImgproxyServiceDocker } from "./services/imgproxy.ts"; import { makeMailpitServiceDocker } from "./services/mailpit.ts"; import { makePgmetaServiceDocker } from "./services/pgmeta.ts"; import { makePoolerServiceDocker } from "./services/pooler.ts"; -import { makePostgresInitService } from "./services/postgres-init.ts"; +import { + makePostgresInitService, + makePostgresInitServiceDocker, +} from "./services/postgres-init.ts"; import { makePostgresService, makePostgresServiceDocker } from "./services/postgres.ts"; import { makePostgrestService, makePostgrestServiceDocker } from "./services/postgrest.ts"; import { makeRealtimeServiceDocker } from "./services/realtime.ts"; @@ -52,7 +58,6 @@ const dependsOnPostgres = (hasPostgresInit: boolean): ReadonlyArray, - hasPostgresInit: boolean, ): StackServiceProjectionCatalog => { const serviceProjection: Map< string, @@ -63,12 +68,14 @@ const publicServiceProjection = ( } > = new Map(defs.map((def) => [def.name, { visibility: "public" as const }] as const)); - if (hasPostgresInit) { - serviceProjection.set("postgres-init", { - visibility: "internal", - owner: "postgres", - ownerStatusWhileActive: "Initializing", - }); + for (const name of ["postgres-init", "postgres-seed"]) { + if (serviceProjection.has(name)) { + serviceProjection.set(name, { + visibility: "internal", + owner: "postgres", + ownerStatusWhileActive: "Initializing", + }); + } } return serviceProjection; @@ -229,9 +236,21 @@ export class StackBuilder extends Context.Service< postgresResolution, dockerServicesEnabled, ); - const hasPostgresInit = postgresResolution.type === "binary"; - const postgresDeps = dependsOnPostgres(hasPostgresInit); - const jwtJwks = generateJwks(config.jwtSecret); + const hasPostgresInit = + postgresResolution.type === "binary" || !config.postgres.autoExposeNewTables; + const initialPostgresDeps = dependsOnPostgres(hasPostgresInit); + const bootstrapRuntime: DatabaseBootstrapRuntime = + postgresResolution.type === "binary" + ? { _tag: "Native", postgresDir: postgresResolution.path } + : { + _tag: "Docker", + containerName: dockerContainerName("postgres", config.apiPort), + }; + const hasSeedPhase = config.databaseBootstrap.seedFiles.length > 0; + const postgresDeps: ReadonlyArray = hasSeedPhase + ? [{ service: "postgres-seed", condition: "completed" }] + : initialPostgresDeps; + const jwtJwks = config.credentials.jwks; const defs: Array = [ { @@ -240,6 +259,7 @@ export class StackBuilder extends Context.Service< binPath: postgresResolution.path, dataDir: config.postgres.dataDir, port: config.dbPort, + startupHealthTimeoutMs: config.postgres.startupHealthTimeoutMs, dockerAccessible: needsDockerAccess, cleanupDataDirOnExit: hasAutoManagedPath(config, config.postgres.dataDir), dependencies: [], @@ -248,6 +268,7 @@ export class StackBuilder extends Context.Service< image: postgresResolution.image, dataDir: config.postgres.dataDir, port: config.dbPort, + startupHealthTimeoutMs: config.postgres.startupHealthTimeoutMs, platformOs: platform.os, jwtSecret: config.jwtSecret, jwtExpiry: config.auth !== false ? config.auth.jwtExpiry : 3600, @@ -259,7 +280,7 @@ export class StackBuilder extends Context.Service< }, ]; - if (hasPostgresInit) { + if (postgresResolution.type === "binary") { defs.push({ ...makePostgresInitService({ postgresDir: postgresResolution.path, @@ -269,6 +290,27 @@ export class StackBuilder extends Context.Service< }), enabled: true, }); + } else if (!config.postgres.autoExposeNewTables) { + defs.push({ + ...makePostgresInitServiceDocker({ + containerName: dockerContainerName("postgres", config.apiPort), + dbPort: config.dbPort, + dependencies: [{ service: "postgres", condition: "healthy" }], + }), + enabled: true, + }); + } + + if (hasSeedPhase) { + defs.push({ + ...makeDatabaseSeedService({ + runtime: bootstrapRuntime, + dbPort: config.dbPort, + seedFiles: config.databaseBootstrap.seedFiles, + dependencies: initialPostgresDeps, + }), + enabled: true, + }); } if (config.postgrest !== false && postgrestResolution !== false) { @@ -309,14 +351,18 @@ export class StackBuilder extends Context.Service< binPath: authResolution.path, dbPort: config.dbPort, authPort: config.auth.port, - siteUrl: config.auth.siteUrl, + config: config.auth, + signing: config.credentials.signing, jwtSecret: config.jwtSecret, - jwtExpiry: config.auth.jwtExpiry, - externalUrl: config.auth.externalUrl, - smtpHost: config.mailpit !== false ? serviceHost : undefined, - smtpPort: config.mailpit !== false ? config.mailpit.smtpPort : undefined, - smtpAdminEmail: config.mailpit !== false ? config.mailpit.adminEmail : undefined, - smtpSenderName: config.mailpit !== false ? config.mailpit.senderName : undefined, + smtpFallback: + config.mailpit === false + ? undefined + : { + host: "127.0.0.1", + port: config.mailpit.smtpTransportPort, + adminEmail: config.mailpit.adminEmail, + senderName: config.mailpit.senderName, + }, dependencies: postgresDeps, }) : makeAuthServiceDocker({ @@ -324,14 +370,18 @@ export class StackBuilder extends Context.Service< dbHost: serviceHost, dbPort: config.dbPort, authPort: config.auth.port, - siteUrl: config.auth.siteUrl, + config: config.auth, + signing: config.credentials.signing, jwtSecret: config.jwtSecret, - jwtExpiry: config.auth.jwtExpiry, - externalUrl: config.auth.externalUrl, - smtpHost: config.mailpit !== false ? serviceHost : undefined, - smtpPort: config.mailpit !== false ? config.mailpit.smtpPort : undefined, - smtpAdminEmail: config.mailpit !== false ? config.mailpit.adminEmail : undefined, - smtpSenderName: config.mailpit !== false ? config.mailpit.senderName : undefined, + smtpFallback: + config.mailpit === false + ? undefined + : { + host: serviceHost, + port: config.mailpit.smtpTransportPort, + adminEmail: config.mailpit.adminEmail, + senderName: config.mailpit.senderName, + }, platformOs: platform.os, apiPort: config.apiPort, dependencies: postgresDeps, @@ -375,8 +425,9 @@ export class StackBuilder extends Context.Service< image: mailpitImage, apiPort: config.apiPort, webPort: config.mailpit.port, - smtpPort: config.mailpit.smtpPort, - pop3Port: config.mailpit.pop3Port, + smtpTransportPort: config.mailpit.smtpTransportPort, + smtpHostPort: config.mailpit.smtpHostPort, + pop3HostPort: config.mailpit.pop3HostPort, platformOs: platform.os, dependencies: [], }), @@ -399,6 +450,7 @@ export class StackBuilder extends Context.Service< encryptionKey: config.realtime.encryptionKey, secretKeyBase: config.realtime.secretKeyBase, maxHeaderLength: config.realtime.maxHeaderLength, + ipVersion: config.realtime.ipVersion, platformOs: platform.os, dependencies: postgresDeps, }), @@ -425,6 +477,7 @@ export class StackBuilder extends Context.Service< imgproxyUrl: config.imgproxy !== false ? `http://${serviceHost}:${config.imgproxy.port}` : "", s3ProtocolEnabled: config.storage.s3ProtocolEnabled, + vectorRuntime: config.storage.vectorRuntime, platformOs: platform.os, dependencies: postgresDeps, cleanupDataDirOnExit: hasAutoManagedPath(config, config.storage.dataDir), @@ -477,6 +530,7 @@ export class StackBuilder extends Context.Service< dbPort: config.dbPort, apiKey: config.analytics.apiKey, backend: config.analytics.backend, + gcp: config.analytics.gcp, dependencies: postgresDeps, }), enabled: true, @@ -533,8 +587,8 @@ export class StackBuilder extends Context.Service< image: studioImage, apiPort: config.apiPort, port: config.studio.port, - apiUrl: config.studio.apiUrl, - publicApiUrl: `http://127.0.0.1:${config.apiPort}`, + apiUrl: `http://${serviceHost}:${config.apiPort}`, + publicApiUrl: config.studio.apiUrl, pgmetaUrl: pgmetaConfig === false ? "" : `http://${serviceHost}:${pgmetaConfig.port}`, publishableKey: config.publishableKey, secretKey: config.secretKey, @@ -546,6 +600,7 @@ export class StackBuilder extends Context.Service< analyticsUrl: config.analytics !== false ? `http://${serviceHost}:${config.analytics.port}` : "", analyticsApiKey: config.analytics !== false ? config.analytics.apiKey : "api-key", + openAiApiKey: config.studio.openAiApiKey, platformOs: platform.os, dependencies: config.analytics === false @@ -579,7 +634,7 @@ export class StackBuilder extends Context.Service< cleanupTargets: { dockerContainerNames, }, - serviceProjection: publicServiceProjection(defs, hasPostgresInit), + serviceProjection: publicServiceProjection(defs), }; }), }); diff --git a/packages/stack/src/StackBuilder.unit.test.ts b/packages/stack/src/StackBuilder.unit.test.ts index a11a0ce5c2..097de91f07 100644 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ b/packages/stack/src/StackBuilder.unit.test.ts @@ -3,6 +3,7 @@ import { Deferred, Effect, Layer, Sink, Stream } from "effect"; import { ChildProcessSpawner } from "effect/unstable/process"; import { mockBinaryResolver } from "../tests/helpers/mocks.ts"; import { defaultPublishableKey, defaultSecretKey, generateJwt } from "./JwtGenerator.ts"; +import { resolveLocalCredentials } from "./LocalCredentials.ts"; import { candidateCleanupTargets } from "./cleanup.ts"; import { StackBuilder } from "./StackBuilder.ts"; import type { BuildResult } from "./StackBuilder.ts"; @@ -15,6 +16,9 @@ import type { StackPreparationInput } from "./StackPreparation.ts"; import { DEFAULT_VERSIONS } from "./versions.ts"; const testJwtSecret = "super-secret-jwt-token-with-at-least-32-characters"; +const testCredentials = resolveLocalCredentials({ + signing: { _tag: "SymmetricJwtSecret", secret: testJwtSecret }, +}); const basePorts: AllocatedPorts = { apiPort: 3000, @@ -45,6 +49,7 @@ const baseConfig: ResolvedStackConfig = { mode: "auto", startupMode: "eager", readiness: DEFAULT_STACK_READINESS_POLICY, + credentials: testCredentials, jwtSecret: testJwtSecret, ports: basePorts, apiPort: 3000, @@ -55,6 +60,7 @@ const baseConfig: ResolvedStackConfig = { autoManagedPaths: [], anonJwt: generateJwt(testJwtSecret, "anon"), serviceRoleJwt: generateJwt(testJwtSecret, "service_role"), + databaseBootstrap: { seedFiles: [] }, postgres: { port: 5432, dataDir: "/tmp/pg-data", @@ -72,8 +78,34 @@ const baseConfig: ResolvedStackConfig = { auth: { port: 9999, siteUrl: "http://localhost:3000", + additionalRedirectUrls: ["http://localhost:3000/**"], jwtExpiry: 3600, + jwtIssuer: "http://localhost:9999", externalUrl: "http://localhost:9999", + enableSignup: true, + enableAnonymousSignIns: false, + enableRefreshTokenRotation: true, + refreshTokenReuseInterval: 10, + enableManualLinking: false, + minimumPasswordLength: 6, + passwordRequirements: "", + email: { + enableSignup: true, + doubleConfirmChanges: true, + enableConfirmations: false, + securePasswordChange: false, + maxFrequency: "1s", + otpLength: 6, + otpExpiry: 3600, + }, + sms: { + enableSignup: false, + enableConfirmations: false, + template: "Your code is {{ .Code }}", + maxFrequency: "5s", + }, + externalProviders: {}, + hooks: {}, version: DEFAULT_VERSIONS.auth, }, edgeRuntime: false, @@ -220,6 +252,166 @@ describe("StackBuilder", () => { }).pipe(Effect.provide(layer)); }); + it.effect("gates native database consumers on the seed bootstrap phase", () => { + const resolver = mockBinaryResolver(); + const layer = builderLayer(resolver); + + return Effect.gen(function* () { + const builder = yield* StackBuilder; + const preparation = yield* StackPreparation; + const { graph, serviceProjection } = yield* prepareAndBuild(builder, preparation, { + ...baseConfig, + databaseBootstrap: { + seedFiles: [ + { + path: "/project/supabase/seed.sql", + historyPath: "supabase/seed.sql", + checksum: "a".repeat(64), + }, + ], + }, + }); + + const names = graph.startOrder.map(({ name }) => name); + const service = (name: string) => + graph.startOrder.find((definition) => definition.name === name); + expect(names.indexOf("postgres-init")).toBeLessThan(names.indexOf("postgres-seed")); + expect(names.indexOf("postgres-seed")).toBeLessThan(names.indexOf("postgrest")); + expect(service("postgres-seed")?.dependencies).toEqual([ + { service: "postgres-init", condition: "completed" }, + ]); + expect(service("auth")?.dependencies).toEqual([ + { service: "postgres-seed", condition: "completed" }, + ]); + expect(service("postgrest")?.dependencies).toEqual([ + { service: "postgres-seed", condition: "completed" }, + ]); + expect(serviceProjection.get("postgres-seed")).toEqual({ + visibility: "internal", + owner: "postgres", + ownerStatusWhileActive: "Initializing", + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "runs Docker seed bootstrap after PostgreSQL health without host file discovery", + () => { + const resolver = mockBinaryResolver(); + const layer = builderLayer(resolver); + + return Effect.gen(function* () { + const builder = yield* StackBuilder; + const preparation = yield* StackPreparation; + const { graph } = yield* prepareAndBuild(builder, preparation, { + ...dockerConfig, + databaseBootstrap: { + seedFiles: [ + { + path: "/project/supabase/seed.sql", + historyPath: "supabase/seed.sql", + checksum: "a".repeat(64), + }, + ], + }, + }); + + const seed = graph.startOrder.find(({ name }) => name === "postgres-seed"); + expect(seed?.dependencies).toEqual([{ service: "postgres", condition: "healthy" }]); + expect(seed?.args).toEqual( + expect.arrayContaining([ + "docker", + "supabase-postgres-3000", + "/project/supabase/seed.sql", + ]), + ); + expect(seed?.args?.[1]).toContain('cat "$file"'); + expect(seed?.args?.[1]).toContain("--single-transaction"); + expect(seed?.args?.[1]).not.toMatch(/docker exec[^\n]*-f/); + expect(graph.startOrder.find(({ name }) => name === "auth")?.dependencies).toEqual([ + { service: "postgres-seed", condition: "completed" }, + ]); + expect(graph.startOrder.map(({ name }) => name)).not.toContain("postgres-init"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "gates Docker database consumers on privilege initialization when auto expose is off", + () => { + const resolver = mockBinaryResolver(); + const layer = builderLayer(resolver); + + return Effect.gen(function* () { + const builder = yield* StackBuilder; + const preparation = yield* StackPreparation; + const { graph, serviceProjection } = yield* prepareAndBuild(builder, preparation, { + ...dockerConfig, + postgres: { ...dockerConfig.postgres, autoExposeNewTables: false }, + }); + + const service = (name: string) => + graph.startOrder.find((definition) => definition.name === name); + const names = graph.startOrder.map(({ name }) => name); + + expect(names.indexOf("postgres")).toBeLessThan(names.indexOf("postgres-init")); + expect(names.indexOf("postgres-init")).toBeLessThan(names.indexOf("postgrest")); + expect(service("postgres-init")?.dependencies).toEqual([ + { service: "postgres", condition: "healthy" }, + ]); + expect(service("postgres-init")?.args).toEqual( + expect.arrayContaining(["supabase-postgres-3000", "5432"]), + ); + expect(service("postgrest")?.dependencies).toEqual([ + { service: "postgres-init", condition: "completed" }, + ]); + expect(service("auth")?.dependencies).toEqual([ + { service: "postgres-init", condition: "completed" }, + ]); + expect(serviceProjection.get("postgres-init")).toEqual({ + visibility: "internal", + owner: "postgres", + ownerStatusWhileActive: "Initializing", + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("runs Docker seed bootstrap after privilege initialization", () => { + const resolver = mockBinaryResolver(); + const layer = builderLayer(resolver); + + return Effect.gen(function* () { + const builder = yield* StackBuilder; + const preparation = yield* StackPreparation; + const { graph } = yield* prepareAndBuild(builder, preparation, { + ...dockerConfig, + postgres: { ...dockerConfig.postgres, autoExposeNewTables: false }, + databaseBootstrap: { + seedFiles: [ + { + path: "/project/supabase/seed.sql", + historyPath: "supabase/seed.sql", + checksum: "a".repeat(64), + }, + ], + }, + }); + + const service = (name: string) => + graph.startOrder.find((definition) => definition.name === name); + expect(service("postgres-seed")?.dependencies).toEqual([ + { service: "postgres-init", condition: "completed" }, + ]); + expect(service("postgrest")?.dependencies).toEqual([ + { service: "postgres-seed", condition: "completed" }, + ]); + expect(service("auth")?.dependencies).toEqual([ + { service: "postgres-seed", condition: "completed" }, + ]); + }).pipe(Effect.provide(layer)); + }); + it.effect("uses docker fallback when auth binary not found", () => { const resolver = mockBinaryResolver({ failServices: ["auth"] }); const layer = builderLayer(resolver); @@ -341,6 +533,98 @@ describe("StackBuilder", () => { }).pipe(Effect.provide(layer)); }); + it.effect("keeps omitted Mailpit protocol ports private to stack transport", () => { + const resolver = mockBinaryResolver(); + const layer = builderLayer(resolver); + const config = { + ...dockerConfig, + postgrest: false, + auth: false, + mailpit: { + port: basePorts.mailpitPort, + smtpTransportPort: basePorts.mailpitSmtpPort, + smtpHostPort: false, + pop3HostPort: false, + version: DEFAULT_VERSIONS.mailpit, + adminEmail: "admin@example.com", + senderName: "Admin", + }, + } satisfies ResolvedStackConfig; + + return Effect.gen(function* () { + const builder = yield* StackBuilder; + const preparation = yield* StackPreparation; + const { graph } = yield* prepareAndBuild(builder, preparation, config); + const args = graph.startOrder.find(({ name }) => name === "mailpit")?.args ?? []; + + expect(args).toContain(`127.0.0.1:${basePorts.mailpitSmtpPort}:1025`); + expect(args).not.toContain(`${basePorts.mailpitPop3Port}:1110`); + }).pipe(Effect.provide(layer)); + }); + + it.effect("publishes explicitly configured Mailpit protocol ports", () => { + const resolver = mockBinaryResolver(); + const layer = builderLayer(resolver); + const config = { + ...dockerConfig, + postgrest: false, + auth: false, + mailpit: { + port: basePorts.mailpitPort, + smtpTransportPort: basePorts.mailpitSmtpPort, + smtpHostPort: basePorts.mailpitSmtpPort, + pop3HostPort: basePorts.mailpitPop3Port, + version: DEFAULT_VERSIONS.mailpit, + adminEmail: "admin@example.com", + senderName: "Admin", + }, + } satisfies ResolvedStackConfig; + + return Effect.gen(function* () { + const builder = yield* StackBuilder; + const preparation = yield* StackPreparation; + const { graph } = yield* prepareAndBuild(builder, preparation, config); + const args = graph.startOrder.find(({ name }) => name === "mailpit")?.args ?? []; + + expect(args).toContain(`${basePorts.mailpitSmtpPort}:1025`); + expect(args).toContain(`${basePorts.mailpitPop3Port}:1110`); + expect(args).not.toContain(`127.0.0.1:${basePorts.mailpitSmtpPort}:1025`); + }).pipe(Effect.provide(layer)); + }); + + it.effect("separates Studio's container API URL from its public browser URL", () => { + const resolver = mockBinaryResolver(); + const layer = builderLayer(resolver); + const publicApiUrl = "https://public.example.test"; + const config = { + ...dockerConfig, + postgrest: false, + auth: false, + pgmeta: { + port: basePorts.pgmetaPort, + version: DEFAULT_VERSIONS.pgmeta, + }, + studio: { + port: basePorts.studioPort, + version: DEFAULT_VERSIONS.studio, + apiUrl: publicApiUrl, + }, + } satisfies ResolvedStackConfig; + + return Effect.gen(function* () { + const builder = yield* StackBuilder; + const preparation = yield* StackPreparation; + const { graph } = yield* prepareAndBuild(builder, preparation, config); + const args = graph.startOrder.find(({ name }) => name === "studio")?.args ?? []; + const internalUrl = args.find((arg) => arg.startsWith("SUPABASE_URL=")); + + expect(args).toContain(`SUPABASE_PUBLIC_URL=${publicApiUrl}`); + expect(internalUrl).toContain(`:${basePorts.apiPort}`); + expect(internalUrl).not.toContain(publicApiUrl); + expect(internalUrl).not.toContain("127.0.0.1"); + }).pipe(Effect.provide(layer)); + }); + it.effect("docker mode wires auth directly to postgres readiness", () => { const resolver = mockBinaryResolver(); const layer = builderLayer(resolver); @@ -460,6 +744,7 @@ describe("StackBuilder", () => { encryptionKey: "supabaserealtime", secretKeyBase: "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", maxHeaderLength: 4096, + ipVersion: "IPv4", }, }); diff --git a/packages/stack/src/StackConfig.ts b/packages/stack/src/StackConfig.ts index 3c108d4170..8234d70202 100644 --- a/packages/stack/src/StackConfig.ts +++ b/packages/stack/src/StackConfig.ts @@ -1,5 +1,7 @@ import { Schema } from "effect"; +import type { AuthRuntimeConfig, ResolvedAuthRuntimeConfig } from "./AuthConfig.ts"; import type { ResolvedFunctionsBundle } from "./functions.ts"; +import type { LocalCredentials, ResolvedLocalCredentials } from "./LocalCredentials.ts"; import type { AllocatedPorts } from "./PortAllocator.ts"; type StackMode = "native" | "auto" | "docker"; @@ -46,6 +48,13 @@ export interface PostgresConfig { readonly port?: number; readonly dataDir?: string; readonly version?: string; + /** + * Startup-health scheduling budget. Factories translate this duration into + * their probe cadence without changing the post-healthy liveness threshold. + * A zero value permits one immediate startup probe. A failing probe may + * finish after the budget because its own execution timeout is independent. + */ + readonly startupHealthTimeoutMs?: number; /** * When true (default), the bundled initial schema GRANTs that expose new tables, views, * sequences, and functions in `public` to the Data API roles (`anon`, `authenticated`, @@ -56,6 +65,24 @@ export interface PostgresConfig { readonly autoExposeNewTables?: boolean; } +export interface DatabaseSeedFile { + /** Absolute path resolved by the caller; the stack never discovers project files. */ + readonly path: string; + /** Stable project-relative key used by the Supabase seed history table. */ + readonly historyPath: string; + /** SHA-256 of the resolved file contents. */ + readonly checksum: string; +} + +export interface DatabaseBootstrapConfig { + /** Seed SQL, already expanded, ordered, and fingerprinted by the caller. */ + readonly seedFiles?: ReadonlyArray; +} + +export interface ResolvedDatabaseBootstrapConfig { + readonly seedFiles: ReadonlyArray; +} + export interface PostgrestConfig { readonly schemas?: ReadonlyArray; readonly extraSearchPath?: ReadonlyArray; @@ -63,13 +90,7 @@ export interface PostgrestConfig { readonly version?: string; } -export interface AuthConfig { - readonly port?: number; - readonly siteUrl?: string; - readonly jwtExpiry?: number; - readonly externalUrl?: string; - readonly version?: string; -} +export type AuthConfig = AuthRuntimeConfig; export interface RealtimeConfig { readonly port?: number; @@ -78,6 +99,7 @@ export interface RealtimeConfig { readonly encryptionKey?: string; readonly secretKeyBase?: string; readonly maxHeaderLength?: number; + readonly ipVersion?: "IPv4" | "IPv6"; } export interface EdgeRuntimeConfig { @@ -94,9 +116,17 @@ export interface StorageConfig { readonly dataDir?: string; readonly fileSizeLimit?: string; readonly s3ProtocolEnabled?: boolean; + readonly vectorRuntime?: StorageVectorRuntimeConfig; readonly version?: string; } +export interface StorageVectorRuntimeConfig { + readonly enabled: string; + readonly provider: string; + readonly migrationsEnabled: string; + readonly databaseUrl?: string; +} + export interface ImgproxyConfig { readonly port?: number; readonly version?: string; @@ -104,8 +134,10 @@ export interface ImgproxyConfig { export interface MailpitConfig { readonly port?: number; - readonly smtpPort?: number; - readonly pop3Port?: number; + /** Host port to publish for SMTP clients, or false to keep SMTP stack-internal. */ + readonly smtpPort?: number | false; + /** Host port to publish for POP3 clients, or false to keep POP3 stack-internal. */ + readonly pop3Port?: number | false; readonly version?: string; readonly adminEmail?: string; readonly senderName?: string; @@ -119,6 +151,7 @@ export interface PgmetaConfig { export interface StudioConfig { readonly port?: number; readonly apiUrl?: string; + readonly openAiApiKey?: string; readonly version?: string; } @@ -127,6 +160,13 @@ export interface AnalyticsConfig { readonly version?: string; readonly backend?: "postgres" | "bigquery"; readonly apiKey?: string; + readonly gcp?: AnalyticsGcpConfig; +} + +export interface AnalyticsGcpConfig { + readonly projectId: string; + readonly projectNumber: string; + readonly credentialsPath: string; } export interface VectorConfig { @@ -155,10 +195,13 @@ export interface StackConfig { readonly startupMode?: StackStartupMode; /** Stack-wide readiness policy. Per-call ReadyOptions take precedence. */ readonly readiness?: ReadinessPolicy; + readonly credentials?: LocalCredentials; + /** @deprecated Prefer the explicit `credentials.signing` domain model. */ readonly jwtSecret?: string; readonly port?: number; readonly publishableKey?: string; readonly secretKey?: string; + readonly databaseBootstrap?: DatabaseBootstrapConfig; readonly functions?: ResolvedFunctionsBundle | false; readonly postgres?: PostgresConfig; readonly postgrest?: PostgrestConfig | false; @@ -179,6 +222,7 @@ export interface ResolvedPostgresConfig { readonly port: number; readonly dataDir: string; readonly version: string; + readonly startupHealthTimeoutMs?: number; readonly autoExposeNewTables: boolean; } @@ -191,13 +235,7 @@ export interface ResolvedPostgrestConfig { readonly version: string; } -export interface ResolvedAuthConfig { - readonly port: number; - readonly siteUrl: string; - readonly jwtExpiry: number; - readonly externalUrl: string; - readonly version: string; -} +export type ResolvedAuthConfig = ResolvedAuthRuntimeConfig; export interface ResolvedRealtimeConfig { readonly port: number; @@ -206,6 +244,7 @@ export interface ResolvedRealtimeConfig { readonly encryptionKey: string; readonly secretKeyBase: string; readonly maxHeaderLength: number; + readonly ipVersion: "IPv4" | "IPv6"; } export interface ResolvedEdgeRuntimeConfig { @@ -223,6 +262,7 @@ export interface ResolvedStorageConfig { readonly dataDir: string; readonly fileSizeLimit: string; readonly s3ProtocolEnabled: boolean; + readonly vectorRuntime?: StorageVectorRuntimeConfig; } export interface ResolvedImgproxyConfig { @@ -232,8 +272,11 @@ export interface ResolvedImgproxyConfig { export interface ResolvedMailpitConfig { readonly port: number; - readonly smtpPort: number; - readonly pop3Port: number; + /** Private loopback bridge used by native or Docker Auth to reach Mailpit. */ + readonly smtpTransportPort: number; + /** Optional user-facing host publications. */ + readonly smtpHostPort: number | false; + readonly pop3HostPort: number | false; readonly version: string; readonly adminEmail: string; readonly senderName: string; @@ -248,6 +291,7 @@ export interface ResolvedStudioConfig { readonly port: number; readonly version: string; readonly apiUrl: string; + readonly openAiApiKey?: string; } export interface ResolvedAnalyticsConfig { @@ -255,6 +299,7 @@ export interface ResolvedAnalyticsConfig { readonly version: string; readonly backend: "postgres" | "bigquery"; readonly apiKey: string; + readonly gcp?: AnalyticsGcpConfig; } export interface ResolvedVectorConfig { @@ -281,6 +326,7 @@ export interface ResolvedStackConfig { readonly mode: StackMode; readonly startupMode: StackStartupMode; readonly readiness: ReadinessPolicy; + readonly credentials: ResolvedLocalCredentials; readonly jwtSecret: string; readonly ports: AllocatedPorts; readonly apiPort: number; @@ -291,6 +337,7 @@ export interface ResolvedStackConfig { readonly autoManagedPaths: ReadonlyArray; readonly anonJwt: string; readonly serviceRoleJwt: string; + readonly databaseBootstrap: ResolvedDatabaseBootstrapConfig; readonly postgres: ResolvedPostgresConfig; readonly postgrest: ResolvedPostgrestConfig | false; readonly auth: ResolvedAuthConfig | false; diff --git a/packages/stack/src/StackConfigResolver.ts b/packages/stack/src/StackConfigResolver.ts index 84bc42bc79..d3fb5b3c5c 100644 --- a/packages/stack/src/StackConfigResolver.ts +++ b/packages/stack/src/StackConfigResolver.ts @@ -3,12 +3,7 @@ import { readdir, readFile } from "node:fs/promises"; import { join } from "node:path"; import { Effect, Schema } from "effect"; import { toStackError } from "./errors.ts"; -import { - defaultJwtSecret, - defaultPublishableKey, - defaultSecretKey, - generateJwt, -} from "./JwtGenerator.ts"; +import { resolveLocalCredentials } from "./LocalCredentials.ts"; import { DEFAULT_MANAGED_STACK_NAME, defaultCacheRoot, @@ -253,11 +248,38 @@ function resolveAuthConfig( ): ResolvedAuthConfig | false { if (raw === false) return false; const cfg = input ?? {}; + const externalUrl = cfg.externalUrl ?? `http://127.0.0.1:${apiPort}/auth/v1`; return { port: ports.authPort, siteUrl: cfg.siteUrl ?? "http://localhost:3000", + additionalRedirectUrls: cfg.additionalRedirectUrls ?? ["https://127.0.0.1:3000"], jwtExpiry: cfg.jwtExpiry ?? 3600, - externalUrl: cfg.externalUrl ?? `http://127.0.0.1:${apiPort}`, + jwtIssuer: cfg.jwtIssuer ?? externalUrl, + externalUrl, + enableSignup: cfg.enableSignup ?? true, + enableAnonymousSignIns: cfg.enableAnonymousSignIns ?? false, + enableRefreshTokenRotation: cfg.enableRefreshTokenRotation ?? true, + refreshTokenReuseInterval: cfg.refreshTokenReuseInterval ?? 10, + enableManualLinking: cfg.enableManualLinking ?? false, + minimumPasswordLength: cfg.minimumPasswordLength ?? 6, + passwordRequirements: cfg.passwordRequirements ?? "", + email: cfg.email ?? { + enableSignup: true, + doubleConfirmChanges: true, + enableConfirmations: false, + securePasswordChange: false, + maxFrequency: "1s", + otpLength: 6, + otpExpiry: 3600, + }, + sms: cfg.sms ?? { + enableSignup: false, + enableConfirmations: false, + template: "Your code is {{ .Code }}", + maxFrequency: "5s", + }, + externalProviders: cfg.externalProviders ?? {}, + hooks: cfg.hooks ?? {}, version: cfg.version ?? DEFAULT_VERSIONS.auth, }; } @@ -277,6 +299,7 @@ function resolveRealtimeConfig( secretKeyBase: cfg.secretKeyBase ?? "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", maxHeaderLength: cfg.maxHeaderLength ?? 4096, + ipVersion: cfg.ipVersion ?? "IPv4", }; } @@ -313,8 +336,9 @@ function resolveStorageConfig( port: ports.storagePort, version: cfg.version ?? DEFAULT_VERSIONS.storage, dataDir: resolveDataDir(cfg.dataDir, opts.stackRoot!, "storage"), - fileSizeLimit: cfg.fileSizeLimit ?? "50MiB", + fileSizeLimit: cfg.fileSizeLimit ?? "52428800", s3ProtocolEnabled: cfg.s3ProtocolEnabled ?? true, + vectorRuntime: cfg.vectorRuntime, }; } @@ -340,8 +364,9 @@ function resolveMailpitConfig( const cfg = input ?? {}; return { port: ports.mailpitPort, - smtpPort: ports.mailpitSmtpPort, - pop3Port: ports.mailpitPop3Port, + smtpTransportPort: ports.mailpitSmtpPort, + smtpHostPort: typeof cfg.smtpPort === "number" ? ports.mailpitSmtpPort : false, + pop3HostPort: typeof cfg.pop3Port === "number" ? ports.mailpitPop3Port : false, version: cfg.version ?? DEFAULT_VERSIONS.mailpit, adminEmail: cfg.adminEmail ?? "admin@email.com", senderName: cfg.senderName ?? "Admin", @@ -373,6 +398,7 @@ function resolveStudioConfig( port: ports.studioPort, version: cfg.version ?? DEFAULT_VERSIONS.studio, apiUrl: cfg.apiUrl ?? `http://127.0.0.1:${apiPort}`, + openAiApiKey: cfg.openAiApiKey, }; } @@ -388,6 +414,7 @@ function resolveAnalyticsConfig( version: cfg.version ?? DEFAULT_VERSIONS.analytics, backend: cfg.backend ?? "postgres", apiKey: cfg.apiKey ?? "api-key", + gcp: cfg.gcp, }; } @@ -474,8 +501,10 @@ export async function resolveConfig( storagePort: storageInput?.port, imgproxyPort: imgproxyInput?.port, mailpitPort: mailpitInput?.port, - mailpitSmtpPort: mailpitInput?.smtpPort, - mailpitPop3Port: mailpitInput?.pop3Port, + mailpitSmtpPort: + typeof mailpitInput?.smtpPort === "number" ? mailpitInput.smtpPort : undefined, + mailpitPop3Port: + typeof mailpitInput?.pop3Port === "number" ? mailpitInput.pop3Port : undefined, pgmetaPort: pgmetaInput?.port, studioPort: studioInput?.port, analyticsPort: analyticsInput?.port, @@ -491,9 +520,16 @@ export async function resolveConfig( throw toStackError(error); }); - const jwtSecret = config.jwtSecret ?? defaultJwtSecret; - const anonJwt = generateJwt(jwtSecret, "anon"); - const serviceRoleJwt = generateJwt(jwtSecret, "service_role"); + const credentials = resolveLocalCredentials({ + ...config.credentials, + signing: + config.credentials?.signing ?? + (config.jwtSecret === undefined + ? undefined + : { _tag: "SymmetricJwtSecret", secret: config.jwtSecret }), + publishableKey: config.credentials?.publishableKey ?? config.publishableKey, + secretKey: config.credentials?.secretKey ?? config.secretKey, + }); return { cacheRoot: roots.cacheRoot, @@ -503,20 +539,25 @@ export async function resolveConfig( mode: resolvedMode, startupMode: config.startupMode ?? "eager", readiness: resolveReadinessPolicy({ stackPolicy: config.readiness }), - jwtSecret, + credentials, + jwtSecret: credentials.jwtSecret, ports, apiPort: ports.apiPort, dbPort: ports.dbPort, - publishableKey: config.publishableKey ?? defaultPublishableKey, - secretKey: config.secretKey ?? defaultSecretKey, + publishableKey: credentials.publishableKey, + secretKey: credentials.secretKey, functions: resolveFunctionsConfig(config), autoManagedPaths: roots.autoManagedPaths, - anonJwt, - serviceRoleJwt, + anonJwt: credentials.anonKey, + serviceRoleJwt: credentials.serviceRoleKey, + databaseBootstrap: { + seedFiles: config.databaseBootstrap?.seedFiles ?? [], + }, postgres: { port: ports.dbPort, dataDir: postgresDataDir, version: postgresInput.version ?? DEFAULT_VERSIONS.postgres, + startupHealthTimeoutMs: postgresInput.startupHealthTimeoutMs, autoExposeNewTables: postgresInput.autoExposeNewTables ?? true, }, postgrest: resolvePostgrestConfig(postgrestInput, config.postgrest, ports), diff --git a/packages/stack/src/StackMetadata.ts b/packages/stack/src/StackMetadata.ts index aa4a3fb0d9..9ea3b868b4 100644 --- a/packages/stack/src/StackMetadata.ts +++ b/packages/stack/src/StackMetadata.ts @@ -44,6 +44,7 @@ const StackLaunchSchema = Schema.Struct({ excludedServices: Schema.Array( Schema.Literals([ "auth", + "edge-runtime", "postgrest", "realtime", "storage", diff --git a/packages/stack/src/StackStateProjection.ts b/packages/stack/src/StackStateProjection.ts index 9d741215b8..4d9835e0b3 100644 --- a/packages/stack/src/StackStateProjection.ts +++ b/packages/stack/src/StackStateProjection.ts @@ -15,7 +15,7 @@ interface StackServiceProjectionSpec { export type StackServiceProjectionCatalog = ReadonlyMap; function isHelperActive(state: RawServiceState): boolean { - return state.status !== "Stopped" && state.status !== "Failed"; + return state.desired === "running" && state.status !== "Stopped" && state.status !== "Failed"; } function projectPublicState( @@ -29,7 +29,9 @@ function projectPublicState( const ownerHelpers = [...rawByName.values()].filter((candidate) => { const spec = catalog.get(candidate.name); - return spec?.visibility === "internal" && spec.owner === raw.name; + return ( + spec?.visibility === "internal" && spec.owner === raw.name && candidate.desired === "running" + ); }); const failedHelper = ownerHelpers.find((helper) => helper.status === "Failed"); diff --git a/packages/stack/src/StackStateProjection.unit.test.ts b/packages/stack/src/StackStateProjection.unit.test.ts index 8148da6fb0..3e5ddea63d 100644 --- a/packages/stack/src/StackStateProjection.unit.test.ts +++ b/packages/stack/src/StackStateProjection.unit.test.ts @@ -7,7 +7,12 @@ import { type StackServiceProjectionCatalog, } from "./StackStateProjection.ts"; -function rawState(name: string, status: ServiceState["status"], error: string | null = null) { +function rawState( + name: string, + status: ServiceState["status"], + error: string | null = null, + desired: ServiceState["desired"] = "running", +) { return new ServiceState({ name, status, @@ -16,7 +21,7 @@ function rawState(name: string, status: ServiceState["status"], error: string | restartCount: 0, startedAt: null, error, - desired: "running", + desired, }); } @@ -60,6 +65,16 @@ describe("projectStackStates", () => { expect(projected.find((state) => state.name === "postgres")?.status).toBe("Initializing"); }); + test("ignores a dormant helper when projecting its owner", () => { + const projected = projectStackState( + "postgres", + [rawState("postgres", "Healthy"), rawState("postgres-init", "Pending", null, "inactive")], + projectionCatalog, + ); + + expect(projected?.status).toBe("Healthy"); + }); + test("propagates helper failure to owner", () => { const projected = projectStackStates( [ diff --git a/packages/stack/src/UnixSocketSse.integration.test.ts b/packages/stack/src/UnixSocketSse.integration.test.ts index c7de5133b0..f2eba71b70 100644 --- a/packages/stack/src/UnixSocketSse.integration.test.ts +++ b/packages/stack/src/UnixSocketSse.integration.test.ts @@ -67,6 +67,7 @@ function makeStackLayer(opts: { name === "postgres" ? Effect.void : Effect.fail(new ServiceNotFoundError({ name })), restartService: (name: string) => name === "postgres" ? Effect.void : Effect.fail(new ServiceNotFoundError({ name })), + configureFunctions: () => Effect.void, reloadFunctions: () => Effect.void, reloadEdgeRuntime: () => Effect.void, getState: (name: string) => diff --git a/packages/stack/src/createStack.ts b/packages/stack/src/createStack.ts index 4864d0223b..c137272879 100644 --- a/packages/stack/src/createStack.ts +++ b/packages/stack/src/createStack.ts @@ -4,7 +4,7 @@ import { HttpServer } from "effect/unstable/http"; import { ChildProcessSpawner } from "effect/unstable/process"; import { candidateCleanupTargets, cleanupAutoManagedPaths, dockerForceRemove } from "./cleanup.ts"; import { toStackError } from "./errors.ts"; -import type { FunctionsReloadConfig } from "./functions.ts"; +import type { FunctionsConfigureConfig, FunctionsReloadConfig } from "./functions.ts"; import { foregroundLayer } from "./layers.ts"; import { PORT_FIELDS, reservePorts, type PortLease } from "./PortAllocator.ts"; import { allocatedPortFieldsForConfig } from "./ServicePorts.ts"; @@ -40,6 +40,7 @@ export interface StackHandle extends AsyncDisposable { startService(name: string): Promise; stopService(name: string): Promise; restartService(name: string): Promise; + configureFunctions(opts: FunctionsConfigureConfig): Promise; reloadFunctions(opts?: FunctionsReloadConfig): Promise; reloadEdgeRuntime(opts: EdgeRuntimeReloadConfig): Promise; ready(opts?: ReadyOptions): Promise; @@ -118,6 +119,7 @@ export async function createStack( startService: (name) => run(localStack.startService(name)), stopService: (name) => run(localStack.stopService(name)), restartService: (name) => run(localStack.restartService(name)), + configureFunctions: (opts) => run(localStack.configureFunctions(opts)), reloadFunctions: (opts) => run(localStack.reloadFunctions(opts)), reloadEdgeRuntime: (opts) => run(localStack.reloadEdgeRuntime(opts)), ready: (opts) => run(localStack.waitAllReady(opts)), diff --git a/packages/stack/src/createStack.unit.test.ts b/packages/stack/src/createStack.unit.test.ts index 667439c64d..c5cc85c970 100644 --- a/packages/stack/src/createStack.unit.test.ts +++ b/packages/stack/src/createStack.unit.test.ts @@ -297,3 +297,10 @@ describe("resolveConfig readiness policy", () => { expect(config.readiness).toEqual({ mode: "infinite" }); }); }); + +describe("resolveConfig postgres startup health", () => { + it("preserves a caller-provided startup timeout for the service factory", async () => { + const config = await resolveConfig({ postgres: { startupHealthTimeoutMs: 75_000 } }); + expect(config.postgres.startupHealthTimeoutMs).toBe(75_000); + }); +}); diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index f1ea9b8407..8a74112674 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -9,6 +9,7 @@ export { ChecksumMismatchError, DockerPullError, DownloadError, + LocalCredentialsError, PortConflictError, StackBuildError, StackError, @@ -35,6 +36,26 @@ export { defaultSecretKey, generateJwt, } from "./JwtGenerator.ts"; +export type { + LocalCredentials, + LocalJwtSigningKey, + LocalJwtSigningMaterial, + ResolvedLocalCredentials, +} from "./LocalCredentials.ts"; +export { + authSigningKeysJson, + resolveLocalCredentials, + validateLocalJwtSigningKeys, +} from "./LocalCredentials.ts"; +export type { + AuthEmailConfig, + AuthExternalProviderConfig, + AuthHookConfig, + AuthRuntimeConfig, + AuthSmsConfig, + PasswordRequirements, + ResolvedAuthRuntimeConfig, +} from "./AuthConfig.ts"; export type { AllocatedPorts, @@ -55,6 +76,8 @@ export { export type { AnalyticsConfig, AuthConfig, + DatabaseBootstrapConfig, + DatabaseSeedFile, EdgeRuntimeConfig, ImgproxyConfig, MailpitConfig, @@ -65,6 +88,7 @@ export type { RealtimeConfig, ResolvedAnalyticsConfig, ResolvedAuthConfig, + ResolvedDatabaseBootstrapConfig, ResolvedEdgeRuntimeConfig, ResolvedImgproxyConfig, ResolvedMailpitConfig, @@ -89,6 +113,7 @@ export { DEFAULT_STACK_READINESS_POLICY, resolveReadinessPolicy } from "./StackC export type { EdgeRuntimeReloadConfig, StackInfo } from "./Stack.ts"; export { EdgeRuntimeReloadConfigSchema, Stack } from "./Stack.ts"; export type { + FunctionsConfigureConfig, FunctionsReloadConfig, FunctionsRuntimeConfig, ResolvedFunction, @@ -97,6 +122,7 @@ export type { export { clearFunctionsRuntimeConfig, configureFunctionsRuntime, + FunctionsConfigureConfigSchema, FunctionsReloadConfigSchema, functionsRuntimeConfigFileName, functionsRuntimeConfigPath, diff --git a/packages/stack/src/errors.ts b/packages/stack/src/errors.ts index 937d3330db..eee67897c5 100644 --- a/packages/stack/src/errors.ts +++ b/packages/stack/src/errors.ts @@ -42,6 +42,11 @@ export class PortConflictError extends Data.TaggedError("PortConflictError")<{ readonly service: string; }> {} +export class LocalCredentialsError extends Data.TaggedError("LocalCredentialsError")<{ + readonly path: string; + readonly detail: string; +}> {} + export class StackError extends Error { readonly code: string; constructor(opts: { code: string; message: string; cause?: unknown }) { @@ -113,6 +118,12 @@ export function toStackError(err: unknown): StackError { message: taggedMessage, cause: err, }); + case "LocalCredentialsError": + return new StackError({ + code: "INVALID_LOCAL_CREDENTIALS", + message: taggedMessage, + cause: err, + }); case "ServiceReadyError": return new StackError({ code: "SERVICE_NOT_READY", diff --git a/packages/stack/src/functions.ts b/packages/stack/src/functions.ts index bcf5c1648e..41a689fe5b 100644 --- a/packages/stack/src/functions.ts +++ b/packages/stack/src/functions.ts @@ -54,21 +54,28 @@ export interface ResolvedFunctionsBundle extends Schema.Schema.Type< typeof ResolvedFunctionsBundleSchema > {} -export const FunctionsReloadConfigSchema = Schema.Struct({ +export const FunctionsConfigureConfigSchema = Schema.Struct({ functions: Schema.optionalKey(ResolvedFunctionsBundleSchema), }); -export interface FunctionsReloadConfig extends Schema.Schema.Type< - typeof FunctionsReloadConfigSchema +export interface FunctionsConfigureConfig extends Schema.Schema.Type< + typeof FunctionsConfigureConfigSchema > {} +export const FunctionsReloadConfigSchema = Schema.Struct({ + functions: Schema.optionalKey(ResolvedFunctionsBundleSchema), +}); + +export interface FunctionsReloadConfig extends FunctionsConfigureConfig {} + export interface FunctionsRuntimeConfig { readonly functionsUrl: string; readonly supabaseUrl: string; readonly dbUrl: string; readonly publishableKey: string; readonly secretKey: string; - readonly jwtSecret: string; + /** Internal verifier set. It may contain symmetric secret material and is never public output. */ + readonly verificationJwks: string; readonly env: Readonly>; readonly functions: Readonly< Record< @@ -113,7 +120,7 @@ export function resolveFunctionsRuntimeConfig( dbUrl: `postgresql://postgres:postgres@${runtimeHost.hostname}:${stackConfig.dbPort}/postgres`, publishableKey: stackConfig.publishableKey, secretKey: stackConfig.secretKey, - jwtSecret: stackConfig.jwtSecret, + verificationJwks: stackConfig.credentials.jwks, env: bundle.env, functions: Object.fromEntries( bundle.functions.map((fn) => [ diff --git a/packages/stack/src/functions.unit.test.ts b/packages/stack/src/functions.unit.test.ts index 2c046a2d28..e56aacbdfa 100644 --- a/packages/stack/src/functions.unit.test.ts +++ b/packages/stack/src/functions.unit.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; +import { generateKeyPairSync } from "node:crypto"; import { mkdtempSync } from "node:fs"; import { readFile, readdir, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; @@ -7,6 +8,8 @@ import { join } from "node:path"; import { Effect, Schema } from "effect"; import { resolveConfig } from "./StackConfigResolver.ts"; import { defaultJwtSecret, generateJwt } from "./JwtGenerator.ts"; +import type { LocalJwtSigningKey, LocalJwtSigningMaterial } from "./LocalCredentials.ts"; +import { resolveLocalCredentials } from "./LocalCredentials.ts"; import { clearFunctionsRuntimeConfig, configureFunctionsRuntime, @@ -15,7 +18,7 @@ import { resolveFunctionsRuntimeConfig, type ResolvedFunctionsBundle, } from "./functions.ts"; -import { verifyRequest } from "./services/edge-runtime-main.ts"; +import { resolveFunctionEnvironment, verifyRequest } from "./services/edge-runtime-main.ts"; function makeTempProject(): string { return mkdtempSync(join(tmpdir(), "supabase-stack-functions-")); @@ -43,6 +46,59 @@ function jwtWithInvalidSignature(algorithm?: string): string { return `${header}.${payload}.invalid`; } +const localEs256Key: LocalJwtSigningKey = { + kty: "EC", + kid: "local-ec-test", + use: "sig", + alg: "ES256", + crv: "P-256", + x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", + y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", + d: "dIhR8wywJlqlua4y_yMq2SLhlFXDZJBCvFrY1DCHyVU", +}; + +function requiredJwkField(value: string | undefined, field: string): string { + if (typeof value !== "string" || value.length === 0) { + throw new Error(`Generated JWK is missing ${field}`); + } + return value; +} + +function localRs256Key(): LocalJwtSigningKey { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + const key = privateKey.export({ format: "jwk" }); + return { + kty: "RSA", + kid: "local-rsa-test", + use: "sig", + alg: "RS256", + n: requiredJwkField(key.n, "n"), + e: requiredJwkField(key.e, "e"), + d: requiredJwkField(key.d, "d"), + p: requiredJwkField(key.p, "p"), + q: requiredJwkField(key.q, "q"), + dp: requiredJwkField(key.dp, "dp"), + dq: requiredJwkField(key.dq, "dq"), + qi: requiredJwkField(key.qi, "qi"), + }; +} + +async function functionsAuthFixture(signing?: LocalJwtSigningMaterial) { + const root = makeTempProject(); + const bundle = makeBundle(root); + const stackConfig = await resolveConfig({ + functions: bundle, + ...(signing === undefined ? {} : { credentials: { signing } }), + }); + const runtimeConfig = resolveFunctionsRuntimeConfig( + stackConfig, + { hostname: "127.0.0.1" }, + bundle, + ); + if (runtimeConfig === undefined) throw new Error("Functions runtime config was not resolved"); + return { root, runtimeConfig, token: stackConfig.anonJwt }; +} + const authFailureCases = [ { name: "returns the missing authorization error", @@ -82,6 +138,29 @@ const authFailureCases = [ ]; describe("stack Functions runtime config", () => { + it("keeps runtime-owned Supabase values above shared and per-function env", () => { + expect( + Object.fromEntries( + resolveFunctionEnvironment( + { + env: { SHARED: "shared", SUPABASE_URL: "shared-url" }, + supabaseUrl: "runtime-url", + publishableKey: "runtime-publishable", + secretKey: "runtime-secret", + dbUrl: "runtime-db", + }, + { SHARED: "function", SUPABASE_URL: "function-url" }, + ), + ), + ).toMatchObject({ + SHARED: "function", + SUPABASE_URL: "runtime-url", + SUPABASE_ANON_KEY: "runtime-publishable", + SUPABASE_SERVICE_ROLE_KEY: "runtime-secret", + SUPABASE_DB_URL: "runtime-db", + }); + }); + it("projects an explicit bundle without project discovery", async () => { const root = makeTempProject(); const stackConfig = await resolveConfig({ functions: makeBundle(root) }); @@ -173,13 +252,14 @@ describe("stack Functions runtime config", () => { }); describe("stack Functions runtime auth", () => { + const defaultVerificationJwks = resolveLocalCredentials(undefined).jwks; for (const { name, authorization, code, message } of authFailureCases) { it(name, async () => { const response = await verifyRequest( new Request("http://127.0.0.1/functions/v1/test", { headers: authorization === undefined ? undefined : { authorization }, }), - { jwtSecret: defaultJwtSecret }, + { verificationJwks: defaultVerificationJwks }, { verifyJWT: true }, ); @@ -200,7 +280,7 @@ describe("stack Functions runtime auth", () => { new Request("http://127.0.0.1/functions/v1/test", { headers: { authorization: `Bearer ${token}` }, }), - { jwtSecret: defaultJwtSecret }, + { verificationJwks: defaultVerificationJwks }, { verifyJWT: true }, ); @@ -213,10 +293,70 @@ describe("stack Functions runtime auth", () => { new Request("http://127.0.0.1/functions/v1/test", { headers: { authorization: `bearer ${token}` }, }), - { jwtSecret: defaultJwtSecret }, + { verificationJwks: defaultVerificationJwks }, { verifyJWT: true }, ); expect(response).toBeNull(); }); + + it("verifies symmetric LocalCredentials through the secure runtime config", async () => { + const fixture = await functionsAuthFixture(); + try { + const response = await verifyRequest( + new Request("http://127.0.0.1/functions/v1/test", { + headers: { authorization: `Bearer ${fixture.token}` }, + }), + fixture.runtimeConfig, + { verifyJWT: true }, + ); + + expect(response).toBeNull(); + expect(fixture.runtimeConfig).not.toHaveProperty("jwtSecret"); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + }); + + it("selects and verifies the matching RS256 LocalCredentials key", async () => { + const fixture = await functionsAuthFixture({ + _tag: "AsymmetricJwtKeys", + legacySecret: defaultJwtSecret, + keys: [localRs256Key()], + }); + try { + const response = await verifyRequest( + new Request("http://127.0.0.1/functions/v1/test", { + headers: { authorization: `Bearer ${fixture.token}` }, + }), + fixture.runtimeConfig, + { verifyJWT: true }, + ); + + expect(response).toBeNull(); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + }); + + it("selects and verifies the matching ES256 LocalCredentials key", async () => { + const fixture = await functionsAuthFixture({ + _tag: "AsymmetricJwtKeys", + legacySecret: defaultJwtSecret, + keys: [localEs256Key], + }); + try { + const response = await verifyRequest( + new Request("http://127.0.0.1/functions/v1/test", { + headers: { authorization: `Bearer ${fixture.token}` }, + }), + fixture.runtimeConfig, + { verifyJWT: true }, + ); + + expect(response).toBeNull(); + } finally { + await rm(fixture.root, { recursive: true, force: true }); + } + }); }); diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 3969233f6f..10bfd42811 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -6,6 +6,8 @@ export type { StackServiceStatus } from "./StackServiceState.ts"; export type { AnalyticsConfig, AuthConfig, + DatabaseBootstrapConfig, + DatabaseSeedFile, EdgeRuntimeConfig, ImgproxyConfig, MailpitConfig, @@ -27,6 +29,7 @@ export type { ServiceResolution } from "./StackPreparation.ts"; export type { PrefetchOptions, PrefetchResult } from "./prefetch.ts"; export type { StackHandle } from "./createStack.ts"; export type { + FunctionsConfigureConfig, FunctionsReloadConfig, FunctionsRuntimeConfig, ResolvedFunction, diff --git a/packages/stack/src/services/analytics.ts b/packages/stack/src/services/analytics.ts index 10d26d28d0..1aab9ef16b 100644 --- a/packages/stack/src/services/analytics.ts +++ b/packages/stack/src/services/analytics.ts @@ -2,6 +2,7 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerPortMapArgs } from "../Platform.ts"; import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; +import type { AnalyticsGcpConfig } from "../StackConfig.ts"; interface DockerAnalyticsOptions { readonly image: string; @@ -12,6 +13,7 @@ interface DockerAnalyticsOptions { readonly dbPort: number; readonly apiKey: string; readonly backend: "postgres" | "bigquery"; + readonly gcp?: AnalyticsGcpConfig; readonly dependencies: ReadonlyArray; } @@ -63,8 +65,8 @@ export const makeAnalyticsServiceDocker = (opts: DockerAnalyticsOptions): Servic env.POSTGRES_BACKEND_SCHEMA = "_analytics"; } else { env.GOOGLE_DATASET_ID_APPEND = "_prod"; - env.GOOGLE_PROJECT_ID = "local"; - env.GOOGLE_PROJECT_NUMBER = "0"; + env.GOOGLE_PROJECT_ID = opts.gcp?.projectId ?? "local"; + env.GOOGLE_PROJECT_NUMBER = opts.gcp?.projectNumber ?? "0"; } return dockerRunService({ @@ -74,6 +76,10 @@ export const makeAnalyticsServiceDocker = (opts: DockerAnalyticsOptions): Servic networkArgs: dockerPortMapArgs(opts.platformOs, [ { host: opts.hostPort, container: ANALYTICS_CONTAINER_PORT }, ]), + volumes: + opts.backend === "bigquery" && opts.gcp !== undefined + ? [`${opts.gcp.credentialsPath}:/opt/app/rel/logflare/bin/gcloud.json:ro`] + : [], entrypoint: "sh", cmd: [ "-c", diff --git a/packages/stack/src/services/auth.ts b/packages/stack/src/services/auth.ts index 6e604695b6..d4154c7892 100644 --- a/packages/stack/src/services/auth.ts +++ b/packages/stack/src/services/auth.ts @@ -1,4 +1,7 @@ import type { ServiceDef } from "@supabase/process-compose"; +import type { AuthEnvironmentInput, ResolvedAuthRuntimeConfig } from "../AuthConfig.ts"; +import { authSigningKeysJson } from "../LocalCredentials.ts"; +import type { LocalJwtSigningMaterial } from "../LocalCredentials.ts"; import { dockerNetworkArgs } from "../Platform.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; @@ -6,14 +9,10 @@ import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; interface AuthServiceOptions { readonly dbPort: number; readonly authPort: number; - readonly siteUrl: string; + readonly config: ResolvedAuthRuntimeConfig; + readonly signing: LocalJwtSigningMaterial; readonly jwtSecret: string; - readonly jwtExpiry: number; - readonly externalUrl: string; - readonly smtpHost?: string; - readonly smtpPort?: number; - readonly smtpAdminEmail?: string; - readonly smtpSenderName?: string; + readonly smtpFallback?: AuthEnvironmentInput["smtpFallback"]; readonly dependencies: ReadonlyArray; } @@ -28,42 +27,164 @@ interface DockerAuthOptions extends AuthServiceOptions { readonly apiPort: number; } -const authEnv = (opts: AuthServiceOptions, dbHost = "127.0.0.1"): Record => ({ - GOTRUE_DB_DATABASE_URL: `postgresql://supabase_auth_admin:postgres@${dbHost}:${opts.dbPort}/postgres`, - GOTRUE_DB_DRIVER: "postgres", - GOTRUE_SITE_URL: opts.siteUrl, - GOTRUE_JWT_SECRET: opts.jwtSecret, - GOTRUE_JWT_EXP: String(opts.jwtExpiry), - GOTRUE_JWT_AUD: "authenticated", - GOTRUE_JWT_ADMIN_ROLES: "service_role", - GOTRUE_JWT_DEFAULT_GROUP_NAME: "authenticated", - API_EXTERNAL_URL: opts.externalUrl, - GOTRUE_API_HOST: "0.0.0.0", - GOTRUE_API_PORT: String(opts.authPort), - GOTRUE_EXTERNAL_EMAIL_ENABLED: "true", - GOTRUE_MAILER_AUTOCONFIRM: "true", - GOTRUE_DISABLE_SIGNUP: "false", - ...(opts.smtpHost === undefined - ? {} - : { - GOTRUE_SMTP_HOST: opts.smtpHost, - GOTRUE_SMTP_PORT: String(opts.smtpPort ?? 1025), - ...(opts.smtpAdminEmail === undefined - ? {} - : { GOTRUE_SMTP_ADMIN_EMAIL: opts.smtpAdminEmail }), - ...(opts.smtpSenderName === undefined - ? {} - : { GOTRUE_SMTP_SENDER_NAME: opts.smtpSenderName }), - }), -}); +const passwordRequirements: Readonly> = { + letters_digits: "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", + lower_upper_letters_digits: "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", + lower_upper_letters_digits_symbols: + "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\\\:\"|<>?,./`~", +}; + +function formatMap(input: Readonly> | undefined): string { + return input === undefined + ? "" + : Object.entries(input) + .map(([key, value]) => `${key}:${value}`) + .join(","); +} + +function appendSmsProvider(env: Record, config: ResolvedAuthRuntimeConfig): void { + const provider = config.sms.provider; + if (provider === undefined) return; + + switch (provider._tag) { + case "twilio": + env["GOTRUE_SMS_PROVIDER"] = "twilio"; + env["GOTRUE_SMS_TWILIO_ACCOUNT_SID"] = provider.accountSid; + env["GOTRUE_SMS_TWILIO_MESSAGE_SERVICE_SID"] = provider.messageServiceSid; + env["GOTRUE_SMS_TWILIO_AUTH_TOKEN"] = provider.authToken; + return; + case "twilio-verify": + env["GOTRUE_SMS_PROVIDER"] = "twilio_verify"; + env["GOTRUE_SMS_TWILIO_VERIFY_ACCOUNT_SID"] = provider.accountSid; + env["GOTRUE_SMS_TWILIO_VERIFY_MESSAGE_SERVICE_SID"] = provider.messageServiceSid; + env["GOTRUE_SMS_TWILIO_VERIFY_AUTH_TOKEN"] = provider.authToken; + return; + case "messagebird": + env["GOTRUE_SMS_PROVIDER"] = "messagebird"; + env["GOTRUE_SMS_MESSAGEBIRD_ORIGINATOR"] = provider.originator; + env["GOTRUE_SMS_MESSAGEBIRD_ACCESS_KEY"] = provider.accessKey; + return; + case "textlocal": + env["GOTRUE_SMS_PROVIDER"] = "textlocal"; + env["GOTRUE_SMS_TEXTLOCAL_SENDER"] = provider.sender; + env["GOTRUE_SMS_TEXTLOCAL_API_KEY"] = provider.apiKey; + return; + case "vonage": + env["GOTRUE_SMS_PROVIDER"] = "vonage"; + env["GOTRUE_SMS_VONAGE_FROM"] = provider.from; + env["GOTRUE_SMS_VONAGE_API_KEY"] = provider.apiKey; + env["GOTRUE_SMS_VONAGE_API_SECRET"] = provider.apiSecret; + return; + } +} + +function appendExternalProviders( + env: Record, + config: ResolvedAuthRuntimeConfig, +): void { + for (const [name, provider] of Object.entries(config.externalProviders)) { + const prefix = `GOTRUE_EXTERNAL_${name.toUpperCase()}`; + env[`${prefix}_ENABLED`] = String(provider.enabled); + env[`${prefix}_CLIENT_ID`] = provider.clientId; + env[`${prefix}_SECRET`] = provider.secret ?? ""; + env[`${prefix}_SKIP_NONCE_CHECK`] = String(provider.skipNonceCheck); + env[`${prefix}_EMAIL_OPTIONAL`] = String(provider.emailOptional); + env[`${prefix}_REDIRECT_URI`] = + provider.redirectUri === undefined || provider.redirectUri.length === 0 + ? `${config.jwtIssuer}/callback` + : provider.redirectUri; + if (provider.url.length > 0) env[`${prefix}_URL`] = provider.url; + } +} -const authHealthCheck = (port: number) => ({ +function appendHooks(env: Record, config: ResolvedAuthRuntimeConfig): void { + for (const [name, hook] of Object.entries(config.hooks)) { + if (!hook.enabled) continue; + const prefix = `GOTRUE_HOOK_${name.toUpperCase()}`; + env[`${prefix}_ENABLED`] = "true"; + env[`${prefix}_URI`] = hook.uri ?? ""; + env[`${prefix}_SECRETS`] = hook.secrets ?? ""; + } +} + +function makeAuthEnvironment(input: AuthEnvironmentInput): Record { + const { config } = input; + const mailerVerifyUrl = `${config.externalUrl.replace(/\/+$/, "")}/verify`; + const env: Record = { + GOTRUE_DB_DATABASE_URL: `postgresql://supabase_auth_admin:postgres@${input.dbHost}:${input.dbPort}/postgres`, + GOTRUE_DB_DRIVER: "postgres", + GOTRUE_SITE_URL: config.siteUrl, + GOTRUE_URI_ALLOW_LIST: config.additionalRedirectUrls.join(","), + GOTRUE_JWT_SECRET: input.jwtSecret, + GOTRUE_JWT_EXP: String(config.jwtExpiry), + GOTRUE_JWT_ISSUER: config.jwtIssuer, + GOTRUE_JWT_AUD: "authenticated", + GOTRUE_JWT_ADMIN_ROLES: "service_role", + GOTRUE_JWT_DEFAULT_GROUP_NAME: "authenticated", + API_EXTERNAL_URL: config.externalUrl, + GOTRUE_API_HOST: "0.0.0.0", + GOTRUE_API_PORT: String(config.port), + GOTRUE_DISABLE_SIGNUP: String(!config.enableSignup), + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: String(config.enableAnonymousSignIns), + GOTRUE_EXTERNAL_EMAIL_ENABLED: String(config.email.enableSignup), + GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED: String(config.email.doubleConfirmChanges), + GOTRUE_MAILER_AUTOCONFIRM: String(!config.email.enableConfirmations), + GOTRUE_MAILER_OTP_LENGTH: String(config.email.otpLength), + GOTRUE_MAILER_OTP_EXP: String(config.email.otpExpiry), + GOTRUE_SMTP_MAX_FREQUENCY: config.email.maxFrequency, + GOTRUE_MAILER_URLPATHS_INVITE: mailerVerifyUrl, + GOTRUE_MAILER_URLPATHS_CONFIRMATION: mailerVerifyUrl, + GOTRUE_MAILER_URLPATHS_RECOVERY: mailerVerifyUrl, + GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: mailerVerifyUrl, + GOTRUE_EXTERNAL_PHONE_ENABLED: String(config.sms.enableSignup), + GOTRUE_SMS_AUTOCONFIRM: String(!config.sms.enableConfirmations), + GOTRUE_SMS_MAX_FREQUENCY: config.sms.maxFrequency, + GOTRUE_SMS_OTP_EXP: "6000", + GOTRUE_SMS_OTP_LENGTH: "6", + GOTRUE_SMS_TEMPLATE: config.sms.template, + GOTRUE_SMS_TEST_OTP: formatMap(config.sms.testOtp), + GOTRUE_PASSWORD_MIN_LENGTH: String(config.minimumPasswordLength), + GOTRUE_PASSWORD_REQUIRED_CHARACTERS: passwordRequirements[config.passwordRequirements] ?? "", + GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED: String(config.enableRefreshTokenRotation), + GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: String(config.refreshTokenReuseInterval), + GOTRUE_SECURITY_MANUAL_LINKING_ENABLED: String(config.enableManualLinking), + GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION: String( + config.email.securePasswordChange, + ), + }; + + const signingKeys = authSigningKeysJson(input.signing); + if (signingKeys !== undefined) { + env["GOTRUE_JWT_KEYS"] = signingKeys; + env["GOTRUE_JWT_VALIDMETHODS"] = "HS256,RS256,ES256"; + env["GOTRUE_JWT_VALID_METHODS"] = "HS256,RS256,ES256"; + } + + const smtp = config.email.smtp ?? input.smtpFallback; + if (smtp !== undefined) { + env["GOTRUE_SMTP_HOST"] = smtp.host; + env["GOTRUE_SMTP_PORT"] = String(smtp.port); + env["GOTRUE_SMTP_ADMIN_EMAIL"] = smtp.adminEmail; + env["GOTRUE_SMTP_SENDER_NAME"] = smtp.senderName ?? ""; + if ("user" in smtp) { + env["GOTRUE_SMTP_USER"] = smtp.user; + env["GOTRUE_SMTP_PASS"] = smtp.pass; + } + } + + appendSmsProvider(env, config); + appendExternalProviders(env, config); + appendHooks(env, config); + return env; +} + +const authHealthCheck = (port: number): NonNullable => ({ probe: { - _tag: "Http" as const, + _tag: "Http", host: "127.0.0.1", port, path: "/health", - scheme: "http" as const, + scheme: "http", }, ...stackHealthBudgets.auth, }); @@ -71,7 +192,14 @@ const authHealthCheck = (port: number) => ({ export const makeAuthServiceNative = (opts: NativeAuthOptions): ServiceDef => ({ name: "auth", command: `${opts.binPath}/auth`, - env: authEnv(opts), + env: makeAuthEnvironment({ + config: opts.config, + signing: opts.signing, + jwtSecret: opts.jwtSecret, + dbHost: "127.0.0.1", + dbPort: opts.dbPort, + smtpFallback: opts.smtpFallback, + }), dependencies: opts.dependencies, healthCheck: authHealthCheck(opts.authPort), supervision: {}, @@ -79,7 +207,14 @@ export const makeAuthServiceNative = (opts: NativeAuthOptions): ServiceDef => ({ }); export const makeAuthServiceDocker = (opts: DockerAuthOptions): ServiceDef => { - const env = authEnv(opts, opts.dbHost); + const env = makeAuthEnvironment({ + config: opts.config, + signing: opts.signing, + jwtSecret: opts.jwtSecret, + dbHost: opts.dbHost, + dbPort: opts.dbPort, + smtpFallback: opts.smtpFallback, + }); return dockerRunService({ name: "auth", apiPort: opts.apiPort, diff --git a/packages/stack/src/services/database-bootstrap.ts b/packages/stack/src/services/database-bootstrap.ts new file mode 100644 index 0000000000..2af9decf81 --- /dev/null +++ b/packages/stack/src/services/database-bootstrap.ts @@ -0,0 +1,142 @@ +import type { ServiceDef } from "@supabase/process-compose"; +import type { DatabaseSeedFile } from "../StackConfig.ts"; +import type { ServiceDependency } from "./service-utils.ts"; + +export type DatabaseBootstrapRuntime = + | { + readonly _tag: "Native"; + readonly postgresDir: string; + } + | { + readonly _tag: "Docker"; + readonly containerName: string; + }; + +interface DatabaseSeedServiceOptions { + readonly runtime: DatabaseBootstrapRuntime; + readonly dbPort: number; + readonly seedFiles: ReadonlyArray; + readonly dependencies: ReadonlyArray; +} + +const psqlRunner = ` +runtime="$1" +runtime_arg="$2" +shift 2 + +run_psql() { + if [ "$runtime" = "native" ]; then + "$runtime_arg" -h 127.0.0.1 "$@" + else + docker exec -i -e PGPASSWORD=postgres "$runtime_arg" psql "$@" + fi +} +`.trim(); + +const psqlOptions = [ + "-p", + "$SUPABASE_BOOTSTRAP_DB_PORT", + "-U", + "postgres", + "-d", + "postgres", + "-v", + "ON_ERROR_STOP=1", + "--no-password", + "--no-psqlrc", +].join(" "); + +// Native psql may open caller-resolved files directly. Docker psql cannot see host paths, so the +// host-side Bash process streams SQL over `docker exec -i`. A new seed payload and its history +// write share one `--single-transaction` session: either both commit or neither does. + +const seedScript = ` +set -euo pipefail +${psqlRunner} + +apply_seed() { + file="$1" + history_path="$2" + checksum="$3" + if [ "$runtime" = "native" ]; then + run_psql ${psqlOptions} --single-transaction -v seed_path="$history_path" -v seed_hash="$checksum" -f "$file" -c "INSERT INTO supabase_migrations.seed_files(path, hash) VALUES (:'seed_path', :'seed_hash') ON CONFLICT (path) DO UPDATE SET hash = EXCLUDED.hash" + else + { + cat "$file" + printf '\n' + cat <<'EOSQL' +INSERT INTO supabase_migrations.seed_files(path, hash) VALUES (:'seed_path', :'seed_hash') ON CONFLICT (path) DO UPDATE SET hash = EXCLUDED.hash; +EOSQL + } | run_psql ${psqlOptions} --single-transaction -v seed_path="$history_path" -v seed_hash="$checksum" + fi +} + +update_seed_hash() { + history_path="$1" + checksum="$2" + run_psql ${psqlOptions} --single-transaction -v seed_path="$history_path" -v seed_hash="$checksum" -c "UPDATE supabase_migrations.seed_files SET hash = :'seed_hash' WHERE path = :'seed_path'" +} + +seed_count="$1" +shift + +if [ "$seed_count" -gt 0 ]; then + run_psql ${psqlOptions} <<'EOSQL' +SET lock_timeout = '4s'; +CREATE SCHEMA IF NOT EXISTS supabase_migrations; +CREATE TABLE IF NOT EXISTS supabase_migrations.seed_files (path text NOT NULL PRIMARY KEY, hash text NOT NULL); +EOSQL +fi + +i=0 +while [ "$i" -lt "$seed_count" ]; do + file="$1" + history_path="$2" + checksum="$3" + shift 3 + applied_hash="$(run_psql ${psqlOptions} -v seed_path="$history_path" -tAc "SELECT hash FROM supabase_migrations.seed_files WHERE path = :'seed_path'" || true)" + if [ -z "$applied_hash" ]; then + echo "Seeding data from $history_path..." + apply_seed "$file" "$history_path" "$checksum" + elif [ "$applied_hash" != "$checksum" ]; then + echo "Updating seed hash to $history_path..." + update_seed_hash "$history_path" "$checksum" + fi + i=$((i + 1)) +done +`.trim(); + +function runtimeArgs(runtime: DatabaseBootstrapRuntime): ReadonlyArray { + return runtime._tag === "Native" + ? ["native", `${runtime.postgresDir}/bin/psql`] + : ["docker", runtime.containerName]; +} + +function runtimeEnv(runtime: DatabaseBootstrapRuntime, dbPort: number): Record { + if (runtime._tag === "Docker") { + return { PGPASSWORD: "postgres", SUPABASE_BOOTSTRAP_DB_PORT: String(dbPort) }; + } + return { + PGPASSWORD: "postgres", + SUPABASE_BOOTSTRAP_DB_PORT: String(dbPort), + DYLD_LIBRARY_PATH: `${runtime.postgresDir}/lib`, + LD_LIBRARY_PATH: `${runtime.postgresDir}/lib`, + }; +} + +export const makeDatabaseSeedService = (opts: DatabaseSeedServiceOptions): ServiceDef => ({ + name: "postgres-seed", + command: "bash", + args: [ + "-c", + seedScript, + "postgres-seed", + ...runtimeArgs(opts.runtime), + String(opts.seedFiles.length), + ...opts.seedFiles.flatMap((file) => [file.path, file.historyPath, file.checksum]), + ], + env: runtimeEnv(opts.runtime, opts.dbPort), + dependencies: opts.dependencies, + supervision: {}, + restart: "no", +}); diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index 7bdd1cd266..620c7e1eae 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -49,6 +49,23 @@ function bytesEqual(left: Uint8Array, right: Uint8Array) { return result === 0; } +interface VerificationJwk { + readonly kty: string; + readonly kid?: string; + readonly alg?: string; + readonly k?: string; + readonly n?: string; + readonly e?: string; + readonly crv?: string; + readonly x?: string; + readonly y?: string; +} + +interface JwtHeader { + readonly alg: string; + readonly kid?: string; +} + function getAuthErrorResponse({ code, message = "Invalid JWT" }: AuthFailure) { return Response.json( { @@ -67,37 +84,107 @@ function getAuthErrorResponse({ code, message = "Invalid JWT" }: AuthFailure) { ); } -function decodeJwtAlgorithm(jwt: string): string | undefined { +function decodeJwtHeader(jwt: string): JwtHeader | undefined { const parts = jwt.split("."); if (parts.length !== 3) { throw new Error("Invalid JWT format"); } - const decodedHeader = JSON.parse(new TextDecoder().decode(base64UrlToBytes(parts[0]!))); - return typeof decodedHeader.alg === "string" ? decodedHeader.alg : undefined; + const decoded: unknown = JSON.parse(new TextDecoder().decode(base64UrlToBytes(parts[0]!))); + if (typeof decoded !== "object" || decoded === null || !("alg" in decoded)) return undefined; + const alg = decoded.alg; + if (typeof alg !== "string") return undefined; + const kid = "kid" in decoded ? decoded.kid : undefined; + return typeof kid === "string" ? { alg, kid } : { alg }; +} + +function verificationKeys(config: { readonly verificationJwks?: unknown }): VerificationJwk[] { + if (typeof config.verificationJwks !== "string") return []; + const decoded: unknown = JSON.parse(config.verificationJwks); + if (typeof decoded !== "object" || decoded === null || !("keys" in decoded)) return []; + const keys = decoded.keys; + if (!Array.isArray(keys)) return []; + return keys.filter( + (key): key is VerificationJwk => + typeof key === "object" && key !== null && "kty" in key && typeof key.kty === "string", + ); +} + +function supportsAlgorithm(key: VerificationJwk, algorithm: string): boolean { + if (key.alg !== undefined && key.alg !== algorithm) return false; + switch (algorithm) { + case "HS256": + return key.kty === "oct" && typeof key.k === "string"; + case "RS256": + return key.kty === "RSA" && typeof key.n === "string" && typeof key.e === "string"; + case "ES256": + return ( + key.kty === "EC" && + key.crv === "P-256" && + typeof key.x === "string" && + typeof key.y === "string" + ); + default: + return false; + } } -async function isValidLocalJwt(secret: string, jwt: string) { +function selectVerificationKey( + keys: ReadonlyArray, + header: JwtHeader, +): VerificationJwk | undefined { + return keys.find( + (key) => + supportsAlgorithm(key, header.alg) && (header.kid === undefined || key.kid === header.kid), + ); +} + +async function isValidLocalJwt(key: VerificationJwk, algorithm: string, jwt: string) { const parts = jwt.split("."); if (parts.length !== 3) return false; const [header, payload, signature] = parts; - const decodedHeader = JSON.parse(new TextDecoder().decode(base64UrlToBytes(header!))); - - // WARN:(kallebysantos) Go version supports Asymmetric JWTs (ES256 | RS256) via SUPABASE_JWKS env - // It must be ported to TS as well - if (decodedHeader.alg !== "HS256") return false; - const key = await crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - const signed = await crypto.subtle.sign( - "HMAC", - key, - new TextEncoder().encode(`${header}.${payload}`), - ); - return bytesEqual(new Uint8Array(signed), base64UrlToBytes(signature!)); + const data = new TextEncoder().encode(`${header}.${payload}`); + const signatureBytes = base64UrlToBytes(signature!); + + if (algorithm === "HS256" && key.k !== undefined) { + const cryptoKey = await crypto.subtle.importKey( + "raw", + base64UrlToBytes(key.k), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const signed = await crypto.subtle.sign("HMAC", cryptoKey, data); + return bytesEqual(new Uint8Array(signed), signatureBytes); + } + + if (algorithm === "RS256" && key.n !== undefined && key.e !== undefined) { + const cryptoKey = await crypto.subtle.importKey( + "jwk", + { kty: "RSA", n: key.n, e: key.e, alg: "RS256", ext: true }, + { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" }, + false, + ["verify"], + ); + return crypto.subtle.verify("RSASSA-PKCS1-v1_5", cryptoKey, signatureBytes, data); + } + + if (algorithm === "ES256" && key.crv === "P-256" && key.x !== undefined && key.y !== undefined) { + const cryptoKey = await crypto.subtle.importKey( + "jwk", + { kty: "EC", crv: "P-256", x: key.x, y: key.y, alg: "ES256", ext: true }, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); + return crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + cryptoKey, + signatureBytes, + data, + ); + } + + return false; } export async function verifyRequest(req: Request, config: any, functionConfig: any) { @@ -125,40 +212,41 @@ export async function verifyRequest(req: Request, config: any, functionConfig: a }); } - let algorithm: string | undefined; + let header: JwtHeader | undefined; try { - algorithm = decodeJwtAlgorithm(token); - } catch (error) { - console.error("JWT format error", error); + header = decodeJwtHeader(token); + } catch { return getAuthErrorResponse({ code: RequestErrors.InvalidTokenFormat, message: "Invalid JWT format", }); } - if (!algorithm) { + if (!header) { return getAuthErrorResponse({ code: RequestErrors.InvalidTokenFormat, message: "Invalid JWT format", }); } - if (algorithm === "HS256") { + if (header.alg === "HS256" || header.alg === "ES256" || header.alg === "RS256") { try { - if (await isValidLocalJwt(config.jwtSecret, token)) return null; - } catch (error) { - console.error("JWT verification failed", error); + const key = selectVerificationKey(verificationKeys(config), header); + if (key !== undefined && (await isValidLocalJwt(key, header.alg, token))) return null; + } catch { + // Verification failures are intentionally opaque and must never log verifier material. } - return getAuthErrorResponse({ code: RequestErrors.InvalidLegacyJWT }); - } - - if (algorithm === "ES256" || algorithm === "RS256") { - return getAuthErrorResponse({ code: RequestErrors.InvalidAsymmetricJWT }); + return getAuthErrorResponse({ + code: + header.alg === "HS256" + ? RequestErrors.InvalidLegacyJWT + : RequestErrors.InvalidAsymmetricJWT, + }); } return getAuthErrorResponse({ code: RequestErrors.UnsupportedTokenAlgorithm, - message: `Unsupported JWT algorithm ${algorithm}`, + message: `Unsupported JWT algorithm ${header.alg}`, }); } @@ -171,13 +259,22 @@ function fileUrl(path: string) { return new URL(`file://${path}`).href; } -async function serveFunction(req: Request, config: any, functionName: string, functionConfig: any) { - const authError = await verifyRequest(req, config, functionConfig); - if (authError) return authError; +interface FunctionsEnvironmentConfig { + readonly env?: Readonly>; + readonly supabaseUrl: string; + readonly publishableKey: string; + readonly secretKey: string; + readonly dbUrl: string; +} - const envVars = Object.entries({ +/** Runtime-owned values intentionally override shared and per-function inputs. */ +export function resolveFunctionEnvironment( + config: FunctionsEnvironmentConfig, + functionEnv: Readonly> | undefined, +) { + return Object.entries({ ...config.env, - ...functionConfig.env, + ...functionEnv, SUPABASE_URL: config.supabaseUrl, SUPABASE_ANON_KEY: config.publishableKey, SUPABASE_SERVICE_ROLE_KEY: config.secretKey, @@ -185,6 +282,13 @@ async function serveFunction(req: Request, config: any, functionName: string, fu SUPABASE_PUBLISHABLE_KEYS: JSON.stringify({ default: config.publishableKey }), SUPABASE_SECRET_KEYS: JSON.stringify({ default: config.secretKey }), }); +} + +async function serveFunction(req: Request, config: any, functionName: string, functionConfig: any) { + const authError = await verifyRequest(req, config, functionConfig); + if (authError) return authError; + + const envVars = resolveFunctionEnvironment(config, functionConfig.env); try { const worker = await EdgeRuntime.userWorkers.create({ diff --git a/packages/stack/src/services/health-budgets.ts b/packages/stack/src/services/health-budgets.ts index d92fecb082..d75c9dea53 100644 --- a/packages/stack/src/services/health-budgets.ts +++ b/packages/stack/src/services/health-budgets.ts @@ -1,12 +1,36 @@ -import type { HealthCheckConfig } from "@supabase/process-compose"; import type { ServiceName } from "../ServiceName.ts"; -type HealthBudget = Required< - Pick< - HealthCheckConfig, - "initialDelaySeconds" | "periodSeconds" | "startupFailureThreshold" | "failureThreshold" - > ->; +export interface HealthBudget { + readonly initialDelaySeconds: number; + readonly periodSeconds: number; + readonly startupFailureThreshold: number; + readonly failureThreshold: number; +} + +/** + * Converts a startup scheduling budget to a probe threshold while retaining + * the factory's liveness policy. An explicit budget also caps the initial + * delay, so zero means one immediate probe. The supervisory transition may + * still overshoot by the duration of the final probe itself; the probe timeout + * remains an independent generic health-check setting. + */ +export function withStartupHealthTimeout( + budget: HealthBudget, + timeoutMs: number | undefined, +): HealthBudget { + if (timeoutMs === undefined) { + return budget; + } + + const normalizedTimeoutMs = Math.max(0, timeoutMs); + const initialDelaySeconds = Math.min(budget.initialDelaySeconds, normalizedTimeoutMs / 1_000); + const probeWindowMs = Math.max(0, normalizedTimeoutMs - initialDelaySeconds * 1_000); + return { + ...budget, + initialDelaySeconds, + startupFailureThreshold: Math.max(1, Math.ceil(probeWindowMs / (budget.periodSeconds * 1_000))), + }; +} /** Cold-start tolerance and tighter post-start liveness thresholds. */ export const stackHealthBudgets = { diff --git a/packages/stack/src/services/health-budgets.unit.test.ts b/packages/stack/src/services/health-budgets.unit.test.ts index 12c7f54428..4293f7ab38 100644 --- a/packages/stack/src/services/health-budgets.unit.test.ts +++ b/packages/stack/src/services/health-budgets.unit.test.ts @@ -4,9 +4,44 @@ import { healthStartupBudgetSeconds, stackHealthBudgets, stackServiceStartupBudgetSeconds, + withStartupHealthTimeout, } from "./health-budgets.ts"; describe("stack health budgets", () => { + it("translates wall-clock startup budgets without changing liveness", () => { + expect(withStartupHealthTimeout(stackHealthBudgets.postgresNative, 120_000)).toEqual({ + ...stackHealthBudgets.postgresNative, + startupFailureThreshold: 240, + }); + expect(withStartupHealthTimeout(stackHealthBudgets.postgresDocker, 120_000)).toEqual({ + ...stackHealthBudgets.postgresDocker, + startupFailureThreshold: 238, + }); + expect(withStartupHealthTimeout(stackHealthBudgets.postgresNative, 0)).toEqual({ + ...stackHealthBudgets.postgresNative, + initialDelaySeconds: 0, + startupFailureThreshold: 1, + }); + expect(withStartupHealthTimeout(stackHealthBudgets.postgresNative, 250)).toEqual({ + ...stackHealthBudgets.postgresNative, + initialDelaySeconds: 0, + startupFailureThreshold: 1, + }); + expect(withStartupHealthTimeout(stackHealthBudgets.postgresDocker, 0)).toEqual({ + ...stackHealthBudgets.postgresDocker, + initialDelaySeconds: 0, + startupFailureThreshold: 1, + }); + expect(withStartupHealthTimeout(stackHealthBudgets.postgresDocker, 500)).toEqual({ + ...stackHealthBudgets.postgresDocker, + initialDelaySeconds: 0.5, + startupFailureThreshold: 1, + }); + expect(withStartupHealthTimeout(stackHealthBudgets.postgresNative, undefined)).toBe( + stackHealthBudgets.postgresNative, + ); + }); + it("records startup and liveness policy for every health-checked service", () => { const summarized = Object.fromEntries( Object.entries(stackHealthBudgets).map(([name, budget]) => [ diff --git a/packages/stack/src/services/mailpit.ts b/packages/stack/src/services/mailpit.ts index edc8673e6a..2173d7bfef 100644 --- a/packages/stack/src/services/mailpit.ts +++ b/packages/stack/src/services/mailpit.ts @@ -1,5 +1,5 @@ import type { ServiceDef } from "@supabase/process-compose"; -import { dockerNetworkArgs } from "../Platform.ts"; +import { dockerPortMapArgs } from "../Platform.ts"; import { dockerRunService, hostHttpHealthCheck, type ServiceDependency } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; @@ -7,12 +7,19 @@ interface DockerMailpitOptions { readonly image: string; readonly apiPort: number; readonly webPort: number; - readonly smtpPort: number; - readonly pop3Port: number; + readonly smtpTransportPort: number; + readonly smtpHostPort: number | false; + readonly pop3HostPort: number | false; readonly platformOs: string; readonly dependencies: ReadonlyArray; } +const mailpitContainerPorts = { + web: 8025, + smtp: 1025, + pop3: 1110, +} as const; + const mailpitHealthCheck = (port: number): ServiceDef["healthCheck"] => hostHttpHealthCheck(port, "/readyz", { ...stackHealthBudgets.mailpit, @@ -23,12 +30,26 @@ export const makeMailpitServiceDocker = (opts: DockerMailpitOptions): ServiceDef name: "mailpit", apiPort: opts.apiPort, image: opts.image, - networkArgs: dockerNetworkArgs(opts.platformOs, [opts.webPort, opts.smtpPort, opts.pop3Port]), + networkArgs: dockerPortMapArgs(opts.platformOs, [ + { host: opts.webPort, container: mailpitContainerPorts.web }, + ...(opts.smtpHostPort === false + ? [ + { + host: opts.smtpTransportPort, + container: mailpitContainerPorts.smtp, + hostAddress: "127.0.0.1", + }, + ] + : [{ host: opts.smtpHostPort, container: mailpitContainerPorts.smtp }]), + ...(opts.pop3HostPort === false + ? [] + : [{ host: opts.pop3HostPort, container: mailpitContainerPorts.pop3 }]), + ]), dependencies: opts.dependencies, env: { - MP_UI_BIND_ADDR: `0.0.0.0:${opts.webPort}`, - MP_SMTP_BIND_ADDR: `0.0.0.0:${opts.smtpPort}`, - MP_POP3_BIND_ADDR: `0.0.0.0:${opts.pop3Port}`, + MP_UI_BIND_ADDR: `0.0.0.0:${mailpitContainerPorts.web}`, + MP_SMTP_BIND_ADDR: `0.0.0.0:${mailpitContainerPorts.smtp}`, + MP_POP3_BIND_ADDR: `0.0.0.0:${mailpitContainerPorts.pop3}`, MP_SMTP_DISABLE_RDNS: "true", }, healthCheck: mailpitHealthCheck(opts.webPort), diff --git a/packages/stack/src/services/postgres-init.ts b/packages/stack/src/services/postgres-init.ts index 63917352b1..080ee54542 100644 --- a/packages/stack/src/services/postgres-init.ts +++ b/packages/stack/src/services/postgres-init.ts @@ -12,6 +12,12 @@ interface PostgresInitOptions { readonly dependencies: ReadonlyArray; } +interface DockerPostgresInitOptions { + readonly containerName: string; + readonly dbPort: number; + readonly dependencies: ReadonlyArray; +} + /** * SQL that matches what Studio runs at cloud project creation when "Default privileges for new * entities" is off. Revokes the default GRANTs installed by the bundled initial schema so new @@ -143,3 +149,32 @@ END restart: "no", }; }; + +const dockerPrivilegeInitScript = ` +set -euo pipefail + +docker exec -i -e PGPASSWORD=postgres "$1" psql \ + -p "$2" \ + -U postgres \ + -d postgres \ + -v ON_ERROR_STOP=1 \ + --no-password \ + --no-psqlrc <<'EOSQL' +${REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL} +EOSQL +`.trim(); + +/** + * Applies the Docker-only post-start privilege policy that cannot be expressed through the + * postgres image's environment. StackBuilder only adds this one-shot phase when automatic Data + * API exposure is disabled. + */ +export const makePostgresInitServiceDocker = (opts: DockerPostgresInitOptions): ServiceDef => ({ + name: "postgres-init", + command: "bash", + args: ["-c", dockerPrivilegeInitScript, "postgres-init", opts.containerName, String(opts.dbPort)], + env: {}, + dependencies: opts.dependencies, + supervision: {}, + restart: "no", +}); diff --git a/packages/stack/src/services/postgres.ts b/packages/stack/src/services/postgres.ts index 296cc5d5bb..e6c37abb3e 100644 --- a/packages/stack/src/services/postgres.ts +++ b/packages/stack/src/services/postgres.ts @@ -3,7 +3,7 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerContainerName } from "../CleanupTargets.ts"; import { dockerNetworkArgs } from "../Platform.ts"; import { removePathOnOrphanCleanup } from "./docker-cleanup.ts"; -import { stackHealthBudgets } from "./health-budgets.ts"; +import { stackHealthBudgets, withStartupHealthTimeout } from "./health-budgets.ts"; import { dockerExecHealthCheck, dockerRunService, @@ -13,6 +13,7 @@ import { interface PostgresServiceOptions { readonly dataDir: string; readonly port: number; + readonly startupHealthTimeoutMs?: number; readonly cleanupDataDirOnExit?: boolean; readonly dependencies: ReadonlyArray; } @@ -84,7 +85,11 @@ const dockerPostgresEntrypoint = (port: number) => ${DOCKER_POSTGRES_SCHEMA_SQL} EOF`; -const postgresHealthCheck = (binPath: string, port: number) => ({ +const postgresHealthCheck = ( + binPath: string, + port: number, + startupHealthTimeoutMs: number | undefined, +) => ({ probe: { _tag: "Exec" as const, command: `${binPath}/bin/pg_isready`, @@ -94,7 +99,7 @@ const postgresHealthCheck = (binPath: string, port: number) => ({ LD_LIBRARY_PATH: `${binPath}/lib`, }, }, - ...stackHealthBudgets.postgresNative, + ...withStartupHealthTimeout(stackHealthBudgets.postgresNative, startupHealthTimeoutMs), }); /** @@ -105,9 +110,13 @@ const postgresHealthCheck = (binPath: string, port: number) => ({ * queries with "unexpected EOF". We use `docker exec` to run pg_isready * inside the container, which verifies postgres is accepting commands. */ -const postgresDockerHealthCheck = (containerName: string, port: number) => +const postgresDockerHealthCheck = ( + containerName: string, + port: number, + startupHealthTimeoutMs: number | undefined, +) => dockerExecHealthCheck(containerName, "pg_isready", ["-p", String(port), "-U", "postgres"], { - ...stackHealthBudgets.postgresDocker, + ...withStartupHealthTimeout(stackHealthBudgets.postgresDocker, startupHealthTimeoutMs), }); export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => { @@ -147,7 +156,7 @@ export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => ], env: postgresEnv(opts), dependencies: opts.dependencies, - healthCheck: postgresHealthCheck(opts.binPath, opts.port), + healthCheck: postgresHealthCheck(opts.binPath, opts.port, opts.startupHealthTimeoutMs), shutdown: { signal: "SIGTERM", timeoutSeconds: 10 }, supervision: { orphanCleanup: [ @@ -165,7 +174,7 @@ export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => args: [initScript, "-p", String(opts.port), ...NATIVE_POSTGRES_RUNTIME_ARGS], env: postgresEnv(opts), dependencies: opts.dependencies, - healthCheck: postgresHealthCheck(opts.binPath, opts.port), + healthCheck: postgresHealthCheck(opts.binPath, opts.port, opts.startupHealthTimeoutMs), shutdown: { signal: "SIGTERM", timeoutSeconds: 10 }, supervision: { orphanCleanup: orphanCleanup(opts) }, restart: "unless-stopped", @@ -185,7 +194,7 @@ export const makePostgresServiceDocker = (opts: DockerPostgresOptions): ServiceD entrypoint: "sh", cmd: ["-c", dockerPostgresEntrypoint(opts.port)], dependencies: opts.dependencies, - healthCheck: postgresDockerHealthCheck(containerName, opts.port), + healthCheck: postgresDockerHealthCheck(containerName, opts.port, opts.startupHealthTimeoutMs), shutdown: { signal: "SIGTERM", timeoutSeconds: 10 }, orphanCleanup: orphanCleanup(opts), }); diff --git a/packages/stack/src/services/realtime.ts b/packages/stack/src/services/realtime.ts index 68910a19e4..7019f37b56 100644 --- a/packages/stack/src/services/realtime.ts +++ b/packages/stack/src/services/realtime.ts @@ -15,6 +15,7 @@ interface DockerRealtimeOptions { readonly encryptionKey: string; readonly secretKeyBase: string; readonly maxHeaderLength: number; + readonly ipVersion: "IPv4" | "IPv6"; readonly platformOs: string; readonly dependencies: ReadonlyArray; } @@ -56,7 +57,7 @@ export const makeRealtimeServiceDocker = (opts: DockerRealtimeOptions): ServiceD METRICS_JWT_SECRET: opts.jwtSecret, APP_NAME: "realtime", SECRET_KEY_BASE: opts.secretKeyBase, - ERL_AFLAGS: "-proto_dist inet_tcp", + ERL_AFLAGS: opts.ipVersion === "IPv6" ? "-proto_dist inet6_tcp" : "-proto_dist inet_tcp", DNS_NODES: "", RLIMIT_NOFILE: "", SEED_SELF_HOST: "true", diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index 119db96f54..da7270693c 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -1,15 +1,18 @@ -import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it } from "vitest"; import { analyticsDockerRuntimeNetwork, makeAnalyticsServiceDocker } from "./analytics.ts"; import { makeAuthServiceNative, makeAuthServiceDocker } from "./auth.ts"; +import { makeDatabaseSeedService } from "./database-bootstrap.ts"; import { makeEdgeRuntimeServiceDocker, makeEdgeRuntimeServiceNative } from "./edge-runtime.ts"; import { makeImgproxyServiceDocker } from "./imgproxy.ts"; import { makeMailpitServiceDocker } from "./mailpit.ts"; import { makePgmetaServiceDocker } from "./pgmeta.ts"; import { makePostgresInitService, + makePostgresInitServiceDocker, REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL, } from "./postgres-init.ts"; import { makePostgresService, makePostgresServiceDocker } from "./postgres.ts"; @@ -32,6 +35,134 @@ const POSTGRES_BIN_PATH = `/cache/postgres/${DEFAULT_VERSIONS.postgres}/darwin-a const POSTGREST_BIN_PATH = `/cache/postgrest/${DEFAULT_VERSIONS.postgrest}/macos-aarch64`; const AUTH_BIN_PATH = `/cache/auth/${DEFAULT_VERSIONS.auth}/arm64`; const EDGE_RUNTIME_BIN_PATH = `/cache/edge-runtime/${DEFAULT_VERSIONS["edge-runtime"]}/aarch64-darwin`; +const AUTH_CONFIG = { + port: 9999, + siteUrl: "http://localhost:3000", + additionalRedirectUrls: ["http://localhost:3000/**"], + jwtExpiry: 3600, + jwtIssuer: `http://127.0.0.1:${API_PORT}/auth/v1`, + externalUrl: `http://127.0.0.1:${API_PORT}/auth/v1`, + enableSignup: true, + enableAnonymousSignIns: false, + enableRefreshTokenRotation: true, + refreshTokenReuseInterval: 10, + enableManualLinking: false, + minimumPasswordLength: 6, + passwordRequirements: "" as const, + email: { + enableSignup: true, + doubleConfirmChanges: true, + enableConfirmations: false, + securePasswordChange: false, + maxFrequency: "1s", + otpLength: 6, + otpExpiry: 3600, + }, + sms: { + enableSignup: false, + enableConfirmations: false, + template: "Your code is {{ .Code }}", + maxFrequency: "5s", + }, + externalProviders: {}, + hooks: {}, + version: DEFAULT_VERSIONS.auth, +}; + +describe("database bootstrap services", () => { + it("passes stable seed history keys and checksums to Docker PostgreSQL", () => { + const def = makeDatabaseSeedService({ + runtime: { _tag: "Docker", containerName: "supabase-postgres-54321" }, + dbPort: DB_PORT, + seedFiles: [ + { + path: "/project/supabase/seed.sql", + historyPath: "supabase/seed.sql", + checksum: "a".repeat(64), + }, + ], + dependencies: [{ service: "postgres", condition: "healthy" }], + }); + + expect(def).toMatchObject({ + name: "postgres-seed", + restart: "no", + dependencies: [{ service: "postgres", condition: "healthy" }], + }); + expect(def.args).toEqual( + expect.arrayContaining([ + "docker", + "supabase-postgres-54321", + "/project/supabase/seed.sql", + "supabase/seed.sql", + "a".repeat(64), + ]), + ); + const script = def.args?.[1] ?? ""; + expect(script).toContain("docker exec -i"); + expect(script).toContain('cat "$file"'); + expect(script).toContain("--single-transaction"); + expect(script).toContain("supabase_migrations.seed_files"); + expect(script).not.toMatch(/docker exec[^\n]*-f/); + }); + + it.each([ + { name: "new", appliedHash: "", appliesSql: true, updatesHashOnly: false }, + { name: "unchanged", appliedHash: "a".repeat(64), appliesSql: false, updatesHashOnly: false }, + { name: "dirty", appliedHash: "b".repeat(64), appliesSql: false, updatesHashOnly: true }, + ])("handles a $name seed according to legacy seed history semantics", (scenario) => { + const tempDir = mkdtempSync(path.join(tmpdir(), "stack-seed-service-")); + try { + const binDir = path.join(tempDir, "bin"); + const logPath = path.join(tempDir, "psql.log"); + mkdirSync(binDir); + writeFileSync( + path.join(binDir, "psql"), + `#!/usr/bin/env bash +printf '%s\n' "$*" >> "$BOOTSTRAP_TEST_LOG" +if [[ "$*" == *"SELECT hash FROM supabase_migrations.seed_files"* ]]; then + printf '%s' "$BOOTSTRAP_TEST_APPLIED_HASH" +fi +cat >/dev/null || true +`, + ); + chmodSync(path.join(binDir, "psql"), 0o755); + const seedPath = path.join(tempDir, "seed.sql"); + writeFileSync(seedPath, "insert into examples values (1);"); + const def = makeDatabaseSeedService({ + runtime: { _tag: "Native", postgresDir: tempDir }, + dbPort: DB_PORT, + seedFiles: [ + { + path: seedPath, + historyPath: "supabase/seed.sql", + checksum: "a".repeat(64), + }, + ], + dependencies: [], + }); + + const result = spawnSync("bash", def.args ?? [], { + encoding: "utf8", + env: { + ...process.env, + ...def.env, + BOOTSTRAP_TEST_LOG: logPath, + BOOTSTRAP_TEST_APPLIED_HASH: scenario.appliedHash, + }, + }); + expect(result.status, result.stderr).toBe(0); + const log = readFileSync(logPath, "utf8"); + expect(log.includes(`-f ${seedPath}`)).toBe(scenario.appliesSql); + expect(log.includes("UPDATE supabase_migrations.seed_files SET hash")).toBe( + scenario.updatesHashOnly, + ); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); + describe("makePostgresService", () => { it("creates a postgres ServiceDef with correct defaults", () => { const def = makePostgresService({ @@ -70,6 +201,39 @@ describe("makePostgresService", () => { expect(def.restart).toBe("unless-stopped"); expect(def.supervision).toBeDefined(); }); + + it("applies a configured startup budget without relaxing liveness", () => { + const def = makePostgresService({ + binPath: POSTGRES_BIN_PATH, + dataDir: "/tmp/supabase/data", + port: DB_PORT, + startupHealthTimeoutMs: 120_000, + dependencies: [], + }); + + expect(def.healthCheck).toMatchObject({ + startupFailureThreshold: 240, + failureThreshold: 30, + }); + }); + + it("runs an immediate native probe for zero and sub-period startup budgets", () => { + for (const startupHealthTimeoutMs of [0, 250]) { + const def = makePostgresService({ + binPath: POSTGRES_BIN_PATH, + dataDir: "/tmp/supabase/data", + port: DB_PORT, + startupHealthTimeoutMs, + dependencies: [], + }); + + expect(def.healthCheck).toMatchObject({ + initialDelaySeconds: 0, + startupFailureThreshold: 1, + failureThreshold: 30, + }); + } + }); }); describe("analyticsDockerRuntimeNetwork", () => { @@ -88,6 +252,113 @@ describe("analyticsDockerRuntimeNetwork", () => { }); }); +describe("data-plane service factories", () => { + it("selects the IPv6 Erlang transport for Realtime", () => { + const def = makeRealtimeServiceDocker({ + image: dockerImageForService("realtime", DEFAULT_VERSIONS.realtime), + port: 54324, + apiPort: API_PORT, + dbHost: "127.0.0.1", + dbPort: DB_PORT, + jwtSecret: JWT_SECRET, + jwtJwks: "{}", + tenantId: "realtime-dev", + encryptionKey: "supabaserealtime", + secretKeyBase: "secret-key-base", + maxHeaderLength: 8192, + ipVersion: "IPv6", + platformOs: "linux", + dependencies: [{ service: "postgres", condition: "healthy" }], + }); + + expect(def.args).toContain("ERL_AFLAGS=-proto_dist inet6_tcp"); + expect(def.args).toContain("MAX_HEADER_LENGTH=8192"); + }); + + it("adds Storage vector runtime env only when configured", () => { + const common = { + image: dockerImageForService("storage", DEFAULT_VERSIONS.storage), + port: 54325, + apiPort: API_PORT, + dbHost: "127.0.0.1", + dbPort: DB_PORT, + dataDir: "/tmp/storage", + anonKey: "anon", + serviceKey: "service", + jwtSecret: JWT_SECRET, + jwtJwks: "{}", + fileSizeLimit: "5242880", + enableImageTransformation: false, + imgproxyUrl: "http://127.0.0.1:54326", + s3ProtocolEnabled: true, + platformOs: "linux", + dependencies: [{ service: "postgres", condition: "healthy" }] as const, + }; + const disabled = makeStorageServiceDocker(common); + const enabled = makeStorageServiceDocker({ + ...common, + vectorRuntime: { + enabled: "true", + provider: "pgvector", + migrationsEnabled: "true", + }, + }); + + expect(disabled.args).not.toContain("VECTOR_ENABLED=true"); + expect(enabled.args).toContain("VECTOR_ENABLED=true"); + expect(enabled.args).toContain("VECTOR_BUCKET_PROVIDER=pgvector"); + expect(enabled.args).toContain( + `VECTOR_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:${DB_PORT}/postgres`, + ); + }); + + it("binds BigQuery credentials and passes Studio's OpenAI key", () => { + const analytics = makeAnalyticsServiceDocker({ + image: dockerImageForService("analytics", DEFAULT_VERSIONS.analytics), + apiPort: API_PORT, + hostPort: 54328, + platformOs: "linux", + dbHost: "127.0.0.1", + dbPort: DB_PORT, + apiKey: "test-api-key", + backend: "bigquery", + gcp: { + projectId: "project-id", + projectNumber: "123", + credentialsPath: "/project/supabase/gcp.json", + }, + dependencies: [{ service: "postgres", condition: "healthy" }], + }); + expect(analytics.args).toContain("GOOGLE_PROJECT_ID=project-id"); + expect(analytics.args).toContain("GOOGLE_PROJECT_NUMBER=123"); + expect(analytics.args).toContain( + "/project/supabase/gcp.json:/opt/app/rel/logflare/bin/gcloud.json:ro", + ); + + const studio = makeStudioServiceDocker({ + image: dockerImageForService("studio", DEFAULT_VERSIONS.studio), + apiPort: API_PORT, + port: 54323, + apiUrl: "http://host.docker.internal:54321", + publicApiUrl: "http://127.0.0.1:54321", + pgmetaUrl: "http://host.docker.internal:54322", + publishableKey: "publishable", + secretKey: "secret", + s3ProtocolAccessKeyId: "local", + s3ProtocolAccessKeySecret: "local-secret", + jwtSecret: JWT_SECRET, + analyticsEnabled: true, + analyticsBackend: "bigquery", + analyticsUrl: "http://host.docker.internal:54327", + analyticsApiKey: "api-key", + openAiApiKey: "openai-secret", + platformOs: "linux", + dependencies: [{ service: "pgmeta", condition: "healthy" }], + }); + expect(studio.args).toContain("OPENAI_API_KEY=openai-secret"); + }); +}); + describe("makeStudioServiceDocker", () => { it("injects legacy keys, opaque keys, and S3 protocol credentials", () => { const def = makeStudioServiceDocker({ @@ -218,6 +489,51 @@ describe("makePostgresServiceDocker", () => { }); }); + it("accounts for Docker's initial delay in a configured startup budget", () => { + const def = makePostgresServiceDocker({ + image: dockerImageForService("postgres", DEFAULT_VERSIONS.postgres), + dataDir: "/tmp/supabase/data", + port: DB_PORT, + platformOs: "linux", + jwtSecret: "test-jwt-secret-with-at-least-32-characters", + jwtExpiry: 3600, + apiPort: API_PORT, + startupHealthTimeoutMs: 120_000, + dependencies: [], + }); + + expect(def.healthCheck).toMatchObject({ + startupFailureThreshold: 238, + failureThreshold: 30, + }); + }); + + it("does not let Docker's default delay exceed zero or sub-delay budgets", () => { + const make = (startupHealthTimeoutMs: number) => + makePostgresServiceDocker({ + image: dockerImageForService("postgres", DEFAULT_VERSIONS.postgres), + dataDir: "/tmp/supabase/data", + port: DB_PORT, + platformOs: "linux", + jwtSecret: "test-jwt-secret-with-at-least-32-characters", + jwtExpiry: 3600, + apiPort: API_PORT, + startupHealthTimeoutMs, + dependencies: [], + }); + + expect(make(0).healthCheck).toMatchObject({ + initialDelaySeconds: 0, + startupFailureThreshold: 1, + failureThreshold: 30, + }); + expect(make(500).healthCheck).toMatchObject({ + initialDelaySeconds: 0.5, + startupFailureThreshold: 1, + failureThreshold: 30, + }); + }); + it("bootstraps auxiliary databases and schemas used by docker-backed services", () => { const def = makePostgresServiceDocker({ image: dockerImageForService("postgres", DEFAULT_VERSIONS.postgres), @@ -312,10 +628,9 @@ describe("makeAuthServiceNative", () => { binPath: AUTH_BIN_PATH, dbPort: DB_PORT, authPort: 9999, - siteUrl: "http://localhost:3000", + config: AUTH_CONFIG, + signing: { _tag: "SymmetricJwtSecret", secret: JWT_SECRET }, jwtSecret: JWT_SECRET, - jwtExpiry: 3600, - externalUrl: `http://127.0.0.1:${API_PORT}`, dependencies: [{ service: "postgres-init", condition: "completed" }], }); @@ -334,6 +649,97 @@ describe("makeAuthServiceNative", () => { }); expect(def.supervision).toBeDefined(); }); + + it("maps Auth policy, SMTP, SMS, external providers, hooks, redirects, and signing keys", () => { + const def = makeAuthServiceNative({ + binPath: AUTH_BIN_PATH, + dbPort: DB_PORT, + authPort: 9999, + jwtSecret: JWT_SECRET, + signing: { + _tag: "AsymmetricJwtKeys", + legacySecret: JWT_SECRET, + keys: [ + { + kty: "EC", + kid: "local-auth-test", + use: "sig", + alg: "ES256", + crv: "P-256", + x: "M5Sjqn5zwC9Kl1zVfUUGvv9boQjCGd45G8sdopBExB4", + y: "P6IXMvA2WYXSHSOMTBH2jsw_9rrzGy89FjPf6oOsIxQ", + d: "dIhR8wywJlqlua4y_yMq2SLhlFXDZJBCvFrY1DCHyVU", + }, + ], + }, + config: { + ...AUTH_CONFIG, + additionalRedirectUrls: ["https://app.example.com/callback"], + jwtExpiry: 7200, + enableSignup: false, + email: { + ...AUTH_CONFIG.email, + enableConfirmations: true, + smtp: { + host: "smtp.example.com", + port: 587, + user: "mailer", + pass: "smtp-secret", + adminEmail: "admin@example.com", + senderName: "Example", + }, + }, + sms: { + ...AUTH_CONFIG.sms, + enableSignup: true, + provider: { + _tag: "twilio", + accountSid: "account", + messageServiceSid: "service", + authToken: "sms-secret", + }, + }, + externalProviders: { + github: { + enabled: true, + clientId: "client", + secret: "provider-secret", + url: "", + skipNonceCheck: false, + emailOptional: false, + }, + }, + hooks: { + custom_access_token: { + enabled: true, + uri: "pg-functions://postgres/auth/custom-access-token", + secrets: "hook-secret", + }, + }, + }, + dependencies: [{ service: "postgres-init", condition: "completed" }], + }); + + expect(def.env).toMatchObject({ + GOTRUE_DISABLE_SIGNUP: "true", + GOTRUE_URI_ALLOW_LIST: "https://app.example.com/callback", + GOTRUE_JWT_EXP: "7200", + GOTRUE_MAILER_AUTOCONFIRM: "false", + GOTRUE_SMTP_HOST: "smtp.example.com", + GOTRUE_SMTP_PASS: "smtp-secret", + GOTRUE_SMS_PROVIDER: "twilio", + GOTRUE_SMS_TWILIO_AUTH_TOKEN: "sms-secret", + GOTRUE_EXTERNAL_GITHUB_ENABLED: "true", + GOTRUE_EXTERNAL_GITHUB_SECRET: "provider-secret", + GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI: `${AUTH_CONFIG.jwtIssuer}/callback`, + GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_ENABLED: "true", + GOTRUE_HOOK_CUSTOM_ACCESS_TOKEN_SECRETS: "hook-secret", + GOTRUE_JWT_VALID_METHODS: "HS256,RS256,ES256", + }); + expect(JSON.parse(def.env?.GOTRUE_JWT_KEYS ?? "[]")).toEqual([ + expect.objectContaining({ kid: "local-auth-test", d: expect.any(String) }), + ]); + }); }); describe("makeAuthServiceDocker", () => { @@ -342,10 +748,9 @@ describe("makeAuthServiceDocker", () => { image: dockerImageForService("auth", DEFAULT_VERSIONS.auth), dbPort: DB_PORT, authPort: 9999, - siteUrl: "http://localhost:3000", + config: AUTH_CONFIG, + signing: { _tag: "SymmetricJwtSecret", secret: JWT_SECRET }, jwtSecret: JWT_SECRET, - jwtExpiry: 3600, - externalUrl: `http://127.0.0.1:${API_PORT}`, dbHost: "127.0.0.1", platformOs: "linux", apiPort: API_PORT, @@ -562,6 +967,32 @@ describe("makePostgresInitService", () => { }); }); +describe("makePostgresInitServiceDocker", () => { + it("creates a one-shot privilege initialization service inside the postgres container", () => { + const dependencies = [{ service: "postgres", condition: "healthy" }] as const; + const def = makePostgresInitServiceDocker({ + containerName: "supabase-postgres-54321", + dbPort: DB_PORT, + dependencies, + }); + + expect(def.name).toBe("postgres-init"); + expect(def.command).toBe("bash"); + expect(def.args).toEqual([ + "-c", + expect.stringContaining(REVOKE_DEFAULT_DATA_API_PRIVILEGES_SQL), + "postgres-init", + "supabase-postgres-54321", + String(DB_PORT), + ]); + expect(def.args?.[1]).toContain('docker exec -i -e PGPASSWORD=postgres "$1" psql'); + expect(def.dependencies).toEqual(dependencies); + expect(def.restart).toBe("no"); + expect(def.healthCheck).toBeUndefined(); + expect(def.supervision).toEqual({}); + }); +}); + describe("docker-backed auxiliary services", () => { it("defines realtime command, topology, environment, and readiness locally", () => { const dependencies = [{ service: "postgres", condition: "healthy" }] as const; @@ -577,6 +1008,7 @@ describe("docker-backed auxiliary services", () => { encryptionKey: "supabaserealtime", secretKeyBase: "test-secret-key-base", maxHeaderLength: 4096, + ipVersion: "IPv4", platformOs: "linux", dependencies, }); @@ -652,8 +1084,9 @@ describe("docker-backed auxiliary services", () => { image: dockerImageForService("mailpit", DEFAULT_VERSIONS.mailpit), apiPort: API_PORT, webPort: 54323, - smtpPort: 54324, - pop3Port: 54325, + smtpTransportPort: 54324, + smtpHostPort: 54324, + pop3HostPort: 54325, platformOs: "linux", dependencies: [], }); diff --git a/packages/stack/src/services/storage.ts b/packages/stack/src/services/storage.ts index cf6f88e33a..eabbac3a20 100644 --- a/packages/stack/src/services/storage.ts +++ b/packages/stack/src/services/storage.ts @@ -3,6 +3,7 @@ import { dockerNetworkArgs } from "../Platform.ts"; import { removePathOnOrphanCleanup } from "./docker-cleanup.ts"; import { dockerRunService, type ServiceDependency } from "./service-utils.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; +import type { StorageVectorRuntimeConfig } from "../StackConfig.ts"; interface DockerStorageOptions { readonly image: string; @@ -19,6 +20,7 @@ interface DockerStorageOptions { readonly enableImageTransformation: boolean; readonly imgproxyUrl: string; readonly s3ProtocolEnabled: boolean; + readonly vectorRuntime?: StorageVectorRuntimeConfig; readonly platformOs: string; readonly dependencies: ReadonlyArray; readonly cleanupDataDirOnExit?: boolean; @@ -43,40 +45,51 @@ const storageHealthCheck = (port: number): ServiceDef["healthCheck"] => ({ ...stackHealthBudgets.storage, }); -export const makeStorageServiceDocker = (opts: DockerStorageOptions): ServiceDef => - dockerRunService({ +export const makeStorageServiceDocker = (opts: DockerStorageOptions): ServiceDef => { + const env: Record = { + PORT: String(opts.port), + ANON_KEY: opts.anonKey, + SERVICE_KEY: opts.serviceKey, + AUTH_JWT_SECRET: opts.jwtSecret, + PGRST_JWT_SECRET: opts.jwtSecret, + JWT_JWKS: opts.jwtJwks, + DATABASE_URL: `postgresql://supabase_storage_admin:postgres@${opts.dbHost}:${opts.dbPort}/postgres`, + FILE_SIZE_LIMIT: opts.fileSizeLimit, + STORAGE_BACKEND: "file", + FILE_STORAGE_BACKEND_PATH: STORAGE_DATA_DIR, + STORAGE_FILE_BACKEND_PATH: STORAGE_DATA_DIR, + TENANT_ID: "stub", + STORAGE_S3_REGION: "local", + GLOBAL_S3_BUCKET: "stub", + ENABLE_IMAGE_TRANSFORMATION: String(opts.enableImageTransformation), + IMGPROXY_URL: opts.imgproxyUrl, + TUS_URL_PATH: "/storage/v1/upload/resumable", + S3_PROTOCOL_ENABLED: String(opts.s3ProtocolEnabled), + S3_PROTOCOL_ACCESS_KEY_ID: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, + S3_PROTOCOL_ACCESS_KEY_SECRET: LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, + S3_PROTOCOL_PREFIX: "/storage/v1", + UPLOAD_FILE_SIZE_LIMIT: "52428800000", + UPLOAD_FILE_SIZE_LIMIT_STANDARD: "5242880000", + SIGNED_UPLOAD_URL_EXPIRATION_TIME: "7200", + }; + if (opts.vectorRuntime !== undefined) { + env.VECTOR_ENABLED = opts.vectorRuntime.enabled; + env.VECTOR_BUCKET_PROVIDER = opts.vectorRuntime.provider; + env.VECTOR_STORE_MIGRATIONS_ENABLED = opts.vectorRuntime.migrationsEnabled; + env.VECTOR_DATABASE_URL = + opts.vectorRuntime.databaseUrl ?? + `postgresql://postgres:postgres@${opts.dbHost}:${opts.dbPort}/postgres`; + } + + return dockerRunService({ name: "storage", apiPort: opts.apiPort, image: opts.image, networkArgs: dockerNetworkArgs(opts.platformOs, [opts.port]), volumes: [`${opts.dataDir}:${STORAGE_DATA_DIR}`], - env: { - PORT: String(opts.port), - ANON_KEY: opts.anonKey, - SERVICE_KEY: opts.serviceKey, - AUTH_JWT_SECRET: opts.jwtSecret, - PGRST_JWT_SECRET: opts.jwtSecret, - JWT_JWKS: opts.jwtJwks, - DATABASE_URL: `postgresql://supabase_storage_admin:postgres@${opts.dbHost}:${opts.dbPort}/postgres`, - FILE_SIZE_LIMIT: opts.fileSizeLimit, - STORAGE_BACKEND: "file", - FILE_STORAGE_BACKEND_PATH: STORAGE_DATA_DIR, - STORAGE_FILE_BACKEND_PATH: STORAGE_DATA_DIR, - TENANT_ID: "stub", - STORAGE_S3_REGION: "local", - GLOBAL_S3_BUCKET: "stub", - ENABLE_IMAGE_TRANSFORMATION: String(opts.enableImageTransformation), - IMGPROXY_URL: opts.imgproxyUrl, - TUS_URL_PATH: "/storage/v1/upload/resumable", - S3_PROTOCOL_ENABLED: String(opts.s3ProtocolEnabled), - S3_PROTOCOL_ACCESS_KEY_ID: LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, - S3_PROTOCOL_ACCESS_KEY_SECRET: LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, - S3_PROTOCOL_PREFIX: "/storage/v1", - UPLOAD_FILE_SIZE_LIMIT: "52428800000", - UPLOAD_FILE_SIZE_LIMIT_STANDARD: "5242880000", - SIGNED_UPLOAD_URL_EXPIRATION_TIME: "7200", - }, + env, dependencies: opts.dependencies, healthCheck: storageHealthCheck(opts.port), orphanCleanup: orphanCleanup(opts), }); +}; diff --git a/packages/stack/src/services/studio.ts b/packages/stack/src/services/studio.ts index f84c24c6a8..67960682c6 100644 --- a/packages/stack/src/services/studio.ts +++ b/packages/stack/src/services/studio.ts @@ -19,6 +19,7 @@ interface DockerStudioOptions { readonly analyticsBackend: "postgres" | "bigquery"; readonly analyticsUrl: string; readonly analyticsApiKey: string; + readonly openAiApiKey?: string; readonly platformOs: string; readonly dependencies: ReadonlyArray; } @@ -60,7 +61,7 @@ export const makeStudioServiceDocker = (opts: DockerStudioOptions): ServiceDef = NEXT_ANALYTICS_BACKEND_PROVIDER: opts.analyticsBackend, HOSTNAME: "0.0.0.0", POSTGRES_USER_READ_WRITE: "postgres", - OPENAI_API_KEY: "", + OPENAI_API_KEY: opts.openAiApiKey ?? "", PGRST_DB_SCHEMAS: "public,graphql_public", PGRST_DB_EXTRA_SEARCH_PATH: "public,extensions", PGRST_DB_MAX_ROWS: "1000",