From fc38a1202ed560184995fb16a48a0fe56ec26251 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 14:52:40 +0200 Subject: [PATCH 01/26] test(cli): codify local stack config parity --- .../next/config/local-stack-config-parity.ts | 523 ++++++++++++++++++ .../local-stack-config-parity.unit.test.ts | 77 +++ 2 files changed, 600 insertions(+) create mode 100644 apps/cli/src/next/config/local-stack-config-parity.ts create mode 100644 apps/cli/src/next/config/local-stack-config-parity.unit.test.ts 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..57a2abdf6a --- /dev/null +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -0,0 +1,523 @@ +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 LocalStackConfigParityDecision = + | { + readonly _tag: "mapped"; + readonly presence: "decoded-value" | "raw-document"; + readonly mappedBy: "start" | "functions-dev" | "stack-functions-runtime"; + readonly rationale: string; + } + | { + readonly _tag: "not-applicable"; + readonly presence: "decoded-value" | "raw-document"; + readonly rationale: string; + } + | { + readonly _tag: "unsupported-blocking"; + readonly presence: "decoded-value" | "raw-document"; + readonly rationale: string; + } + | { + readonly _tag: "unsupported-warning"; + readonly presence: "decoded-value" | "raw-document"; + readonly rationale: string; + }; + +export interface LocalStackConfigParitySection { + readonly [field: string]: LocalStackConfigParityDecision | LocalStackConfigParitySection; +} + +type Node = LocalStackConfigParityDecision | 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: "decoded-value", + rationale: + "An explicitly configured secret changes local runtime credentials but the next stack launch Adapter does not translate it yet.", +}; + +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 mappedFunctionManifest: LocalStackConfigParityDecision = { + _tag: "mapped", + presence: "raw-document", + mappedBy: "stack-functions-runtime", + rationale: + "The current stack functions runtime resolves every configured function entry, including enablement, JWT verification, paths, static files, and environment values.", +}; + +const functionConfigParity = { + enabled: mappedFunctionManifest, + verify_jwt: mappedFunctionManifest, + import_map: mappedFunctionManifest, + entrypoint: mappedFunctionManifest, + static_files: mappedFunctionManifest, + env: mappedFunctionManifest, +} satisfies Record; + +const mappedFunctionsDevEdgeRuntime: LocalStackConfigParityDecision = { + _tag: "mapped", + presence: "raw-document", + mappedBy: "functions-dev", + rationale: + "The functions-dev Adapter resolves this field and passes it to the stack edge-runtime configuration.", +}; + +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 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 authExternalProviderParity = { + enabled: unsupportedRuntimeField, + client_id: unsupportedRuntimeField, + secret: unsupportedSecretRuntimeField, + url: unsupportedRuntimeField, + redirect_uri: unsupportedRuntimeField, + skip_nonce_check: unsupportedRuntimeField, + email_optional: unsupportedRuntimeField, +} satisfies Record; + +const authHookParity = { + enabled: unsupportedRuntimeField, + uri: unsupportedOptionalRuntimeField, + secrets: unsupportedSecretRuntimeField, +} satisfies Record; + +const authRateLimitParity = { + email_sent: unsupportedRuntimeField, + 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 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: unsupportedRuntimeField, + enable_confirmations: unsupportedRuntimeField, + template: unsupportedRuntimeField, + max_frequency: unsupportedRuntimeField, + twilio: { + enabled: unsupportedRuntimeField, + account_sid: unsupportedRuntimeField, + message_service_sid: unsupportedRuntimeField, + auth_token: unsupportedSecretRuntimeField, + } satisfies Record, + twilio_verify: { + enabled: unsupportedRuntimeField, + account_sid: unsupportedOptionalRuntimeField, + message_service_sid: unsupportedOptionalRuntimeField, + auth_token: unsupportedSecretRuntimeField, + } satisfies Record, + messagebird: { + enabled: unsupportedRuntimeField, + originator: unsupportedOptionalRuntimeField, + access_key: unsupportedSecretRuntimeField, + } satisfies Record, + textlocal: { + enabled: unsupportedRuntimeField, + sender: unsupportedOptionalRuntimeField, + api_key: unsupportedSecretRuntimeField, + } satisfies Record, + vonage: { + enabled: unsupportedRuntimeField, + from: unsupportedOptionalRuntimeField, + api_key: unsupportedOptionalRuntimeField, + api_secret: unsupportedSecretRuntimeField, + } satisfies Record, + test_otp: unsupportedOptionalRuntimeField, +} satisfies Record; + +const authParity = { + enabled: unsupportedRuntimeField, + site_url: unsupportedRuntimeField, + additional_redirect_urls: unsupportedRuntimeField, + jwt_expiry: unsupportedRuntimeField, + jwt_issuer: unsupportedOptionalRuntimeField, + signing_keys_path: unsupportedOptionalRuntimeField, + enable_refresh_token_rotation: unsupportedRuntimeField, + refresh_token_reuse_interval: unsupportedRuntimeField, + enable_manual_linking: unsupportedRuntimeField, + enable_signup: unsupportedRuntimeField, + enable_anonymous_sign_ins: unsupportedRuntimeField, + minimum_password_length: unsupportedRuntimeField, + password_requirements: unsupportedRuntimeField, + publishable_key: unsupportedSecretRuntimeField, + secret_key: unsupportedSecretRuntimeField, + jwt_secret: unsupportedSecretRuntimeField, + anon_key: unsupportedSecretRuntimeField, + service_role_key: unsupportedSecretRuntimeField, + rate_limit: authRateLimitParity, + captcha: { + enabled: unsupportedRuntimeField, + provider: unsupportedOptionalRuntimeField, + secret: unsupportedSecretRuntimeField, + } satisfies Record, Node>, + hook: authHooksParity, + mfa: { + totp: { + enroll_enabled: unsupportedRuntimeField, + verify_enabled: unsupportedRuntimeField, + } satisfies Record, + phone: { + enroll_enabled: unsupportedRuntimeField, + verify_enabled: unsupportedRuntimeField, + otp_length: unsupportedRuntimeField, + template: unsupportedRuntimeField, + max_frequency: unsupportedRuntimeField, + } satisfies Record, + web_authn: { + enroll_enabled: unsupportedRuntimeField, + verify_enabled: unsupportedRuntimeField, + } satisfies Record, + max_enrolled_factors: unsupportedRuntimeField, + } satisfies Record, + sessions: { + timebox: unsupportedOptionalRuntimeField, + inactivity_timeout: unsupportedOptionalRuntimeField, + } satisfies Record, Node>, + email: { + enable_signup: unsupportedRuntimeField, + double_confirm_changes: unsupportedRuntimeField, + enable_confirmations: unsupportedRuntimeField, + secure_password_change: unsupportedRuntimeField, + max_frequency: unsupportedRuntimeField, + otp_length: unsupportedRuntimeField, + otp_expiry: unsupportedRuntimeField, + smtp: { + enabled: unsupportedRuntimeField, + host: unsupportedOptionalRuntimeField, + port: unsupportedOptionalRuntimeField, + user: unsupportedOptionalRuntimeField, + pass: unsupportedSecretRuntimeField, + admin_email: unsupportedOptionalRuntimeField, + sender_name: unsupportedOptionalRuntimeField, + } 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: authExternalParity, + web3: { + solana: { + enabled: unsupportedRuntimeField, + } satisfies Record, + ethereum: { + enabled: unsupportedRuntimeField, + } satisfies Record, + } satisfies Record, + oauth_server: { + enabled: unsupportedRuntimeField, + authorization_url_path: unsupportedRuntimeField, + allow_dynamic_registration: unsupportedRuntimeField, + } satisfies Record, + third_party: { + firebase: { + enabled: unsupportedRuntimeField, + project_id: unsupportedOptionalRuntimeField, + } satisfies Record, + auth0: { + enabled: unsupportedRuntimeField, + tenant: unsupportedOptionalRuntimeField, + tenant_region: unsupportedOptionalRuntimeField, + } satisfies Record, + aws_cognito: { + enabled: unsupportedRuntimeField, + user_pool_id: unsupportedOptionalRuntimeField, + user_pool_region: unsupportedOptionalRuntimeField, + } satisfies Record, + clerk: { + enabled: unsupportedRuntimeField, + domain: unsupportedOptionalRuntimeField, + } satisfies Record, + workos: { + enabled: unsupportedRuntimeField, + issuer_url: unsupportedOptionalRuntimeField, + } satisfies Record, + } satisfies Record, +} satisfies Record; + +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: unsupportedOptionalRuntimeField, + analytics: { + enabled: unsupportedRuntimeField, + port: unsupportedRuntimeField, + backend: unsupportedRuntimeField, + vector_port: unsupportedOptionalRuntimeField, + gcp_project_id: unsupportedOptionalRuntimeField, + gcp_project_number: unsupportedOptionalRuntimeField, + gcp_jwt_path: unsupportedOptionalRuntimeField, + } satisfies Record, + api: { + enabled: unsupportedRuntimeField, + port: unsupportedRuntimeField, + schemas: unsupportedRuntimeField, + extra_search_path: unsupportedRuntimeField, + max_rows: unsupportedRuntimeField, + auto_expose_new_tables: mappedAutoExposeNewTables, + tls: { + enabled: unsupportedRuntimeField, + cert_path: unsupportedOptionalRuntimeField, + key_path: unsupportedOptionalRuntimeField, + } satisfies Record, + external_url: unsupportedOptionalRuntimeField, + } satisfies Record, + auth: authParity, + db: { + port: unsupportedRuntimeField, + shadow_port: commandOnlyDatabaseField, + health_timeout: unsupportedRuntimeField, + major_version: unsupportedRuntimeField, + pooler: { + enabled: unsupportedRuntimeField, + port: unsupportedRuntimeField, + pool_mode: unsupportedRuntimeField, + default_pool_size: unsupportedRuntimeField, + max_client_conn: unsupportedRuntimeField, + } satisfies Record, + migrations: { + enabled: unsupportedRuntimeField, + schema_paths: unsupportedRuntimeField, + } satisfies Record, + seed: { + enabled: unsupportedRuntimeField, + sql_paths: unsupportedRuntimeField, + } 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: mappedFunctionsDevEdgeRuntime, + policy: mappedFunctionsDevEdgeRuntime, + inspector_port: mappedFunctionsDevEdgeRuntime, + deno_version: unsupportedRuntimeField, + secrets: mappedFunctionsDevEdgeRuntime, + } satisfies Record, + functions: { + "*": functionConfigParity, + }, + local_smtp: { + enabled: unsupportedRuntimeField, + port: unsupportedRuntimeField, + smtp_port: unsupportedOptionalRuntimeField, + pop3_port: unsupportedOptionalRuntimeField, + admin_email: unsupportedOptionalRuntimeField, + sender_name: unsupportedOptionalRuntimeField, + } satisfies Record, + realtime: { + enabled: unsupportedRuntimeField, + ip_version: unsupportedRuntimeField, + max_header_length: unsupportedRuntimeField, + } satisfies Record, + storage: { + enabled: unsupportedRuntimeField, + file_size_limit: unsupportedRuntimeField, + image_transformation: { + enabled: unsupportedRuntimeField, + } satisfies Record, Node>, + buckets: { + "*": { + public: unsupportedRuntimeField, + file_size_limit: unsupportedRuntimeField, + allowed_mime_types: unsupportedRuntimeField, + objects_path: unsupportedRuntimeField, + } satisfies Record[string], Node>, + }, + s3_protocol: { + enabled: unsupportedRuntimeField, + } satisfies Record, + analytics: { + enabled: unsupportedRuntimeField, + max_namespaces: unsupportedRuntimeField, + max_tables: unsupportedRuntimeField, + max_catalogs: unsupportedRuntimeField, + buckets: unsupportedRuntimeField, + } satisfies Record, + vector: { + enabled: unsupportedRuntimeField, + max_buckets: unsupportedRuntimeField, + max_indexes: unsupportedRuntimeField, + buckets: unsupportedRuntimeField, + } satisfies Record, + } satisfies Record, + studio: { + enabled: unsupportedRuntimeField, + port: unsupportedRuntimeField, + api_url: unsupportedRuntimeField, + openai_api_key: unsupportedSecretRuntimeField, + } 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; +} + +function isDecision(node: Node): node is LocalStackConfigParityDecision { + return "_tag" in node; +} + +/** Flattens the nested, compile-checked ledger for diagnostics and tests. */ +export function flattenLocalStackConfigParity( + section: LocalStackConfigParitySection = localStackConfigParity, + prefix = "", +): ReadonlyArray { + return Object.entries(section).flatMap(([field, node]) => { + const path = prefix === "" ? field : `${prefix}.${field}`; + return isDecision(node) + ? [{ path, decision: node }] + : flattenLocalStackConfigParity(node, path); + }); +} 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..69a75e96a4 --- /dev/null +++ b/apps/cli/src/next/config/local-stack-config-parity.unit.test.ts @@ -0,0 +1,77 @@ +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(361); + 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: 11, + "not-applicable": 9, + "unsupported-blocking": 335, + "unsupported-warning": 6, + }); + }); + + it("claims only behavior implemented by a current next local-runtime flow as mapped", () => { + expect( + entries + .filter(({ decision }) => decision._tag === "mapped") + .map(({ path }) => path) + .sort(), + ).toEqual([ + "api.auto_expose_new_tables", + "edge_runtime.enabled", + "edge_runtime.inspector_port", + "edge_runtime.policy", + "edge_runtime.secrets", + "functions.*.enabled", + "functions.*.entrypoint", + "functions.*.env", + "functions.*.import_map", + "functions.*.static_files", + "functions.*.verify_jwt", + ]); + }); + + it("preserves raw-document 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("raw-document"); + expect(byPath.get("auth.hook.send_email.enabled")?.presence).toBe("raw-document"); + expect(byPath.get("auth.sms.twilio.enabled")?.presence).toBe("raw-document"); + expect(byPath.get("storage.image_transformation.enabled")?.presence).toBe("raw-document"); + expect(byPath.get("experimental.webhooks.enabled")?.presence).toBe("raw-document"); + }); + + it("keeps non-runtime project configuration out of StackConfig", () => { + expect( + entries + .filter(({ decision }) => decision._tag === "not-applicable") + .map(({ path }) => path) + .sort(), + ).toEqual([ + "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", + "remotes", + ]); + }); +}); From 259d5f12fc48a0a2dc4a0f38c93c5cb846c1e8ff Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 15:20:40 +0200 Subject: [PATCH 02/26] refactor(cli): centralize local stack launch configuration --- apps/cli/README.md | 7 + .../commands/start/services/gotrue.service.ts | 2 +- .../legacy/commands/start/start.handler.ts | 2 +- .../branches/switch/switch.handler.ts | 14 +- .../functions/dev/functions-dev-runtime.ts | 4 +- .../src/next/commands/start/start.command.ts | 67 ++--- .../commands/start/start.command.unit.test.ts | 30 +- .../next/commands/update/update.handler.ts | 11 +- .../next/config/local-stack-config-parity.ts | 2 +- .../config/stack-config.integration.test.ts | 70 +++++ apps/cli/src/next/config/stack-config.ts | 273 +++++++++++++++++- .../src/next/config/stack-config.unit.test.ts | 200 ++++++++++--- .../config/go-duration.ts} | 2 +- .../config/go-duration.unit.test.ts} | 2 +- 14 files changed, 550 insertions(+), 136 deletions(-) create mode 100644 apps/cli/src/next/config/stack-config.integration.test.ts rename apps/cli/src/{legacy/shared/legacy-go-duration.ts => shared/config/go-duration.ts} (99%) rename apps/cli/src/{legacy/shared/legacy-go-duration.unit.test.ts => shared/config/go-duration.unit.test.ts} (99%) 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/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/start.handler.ts b/apps/cli/src/legacy/commands/start/start.handler.ts index 5902a8dac9..a514a991c1 100644 --- a/apps/cli/src/legacy/commands/start/start.handler.ts +++ b/apps/cli/src/legacy/commands/start/start.handler.ts @@ -48,7 +48,7 @@ import { } from "../../shared/legacy-vault-decrypt.ts"; import { ramInBytes } from "../../shared/legacy-size-units.ts"; import { legacyTempPaths } from "../../shared/legacy-temp-paths.ts"; -import { legacyParseGoDuration } from "../../shared/legacy-go-duration.ts"; +import { legacyParseGoDuration } from "../../../shared/config/go-duration.ts"; import { LEGACY_CLI_PROJECT_LABEL, legacyCliProjectFilterValue, 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 86cbdb35a3..7e35d08d3a 100644 --- a/apps/cli/src/next/commands/branches/switch/switch.handler.ts +++ b/apps/cli/src/next/commands/branches/switch/switch.handler.ts @@ -8,7 +8,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"; @@ -144,13 +144,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-runtime.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-runtime.ts index c3a1e69722..98ca7dd376 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 @@ -18,7 +18,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 { @@ -54,7 +54,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 4db1fc276d..baf6900205 100644 --- a/apps/cli/src/next/commands/start/start.command.ts +++ b/apps/cli/src/next/commands/start/start.command.ts @@ -14,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 { @@ -23,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"; @@ -37,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( @@ -162,6 +137,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( @@ -175,25 +151,25 @@ 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); @@ -201,10 +177,9 @@ export const startCommand = Command.make("start", flags).pipe( { cacheRoot: cliConfig.supabaseHome, cwd: runtimeInfo.cwd, - projectDir: projectHome.projectRoot, projectStateRoot: projectHome.projectHomeDir, name: flags.stack, - ...stackConfig, + ...launch.stackConfig, }, daemonEntryPoint, ); 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/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/local-stack-config-parity.ts b/apps/cli/src/next/config/local-stack-config-parity.ts index 57a2abdf6a..516bd0a2b8 100644 --- a/apps/cli/src/next/config/local-stack-config-parity.ts +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -9,7 +9,7 @@ import type { ProjectConfig } from "@supabase/config"; * explicitly configured default value, which matters when unsupported fields * must be rejected or warned about without rejecting untouched defaults. */ -type LocalStackConfigParityDecision = +export type LocalStackConfigParityDecision = | { readonly _tag: "mapped"; readonly presence: "decoded-value" | "raw-document"; 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..a6681650ab --- /dev/null +++ b/apps/cli/src/next/config/stack-config.integration.test.ts @@ -0,0 +1,70 @@ +import { loadProjectConfig, loadProjectEnvironmentFor } from "@supabase/config/node"; +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"; + +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"), + [ + 'project_id = "launch-test"', + "", + "[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( + resolveLocalStackLaunch({ + 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.postgresStartupTimeoutMs).toBe(7_000); + expect(result.readiness).toEqual({ mode: "finite", timeoutMs: 37_000 }); + expect(result.warnings).toEqual([ + expect.objectContaining({ + code: "unsupported", + paths: ["experimental.webhooks.enabled"], + }), + ]); + } 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..334e926eb8 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -1,4 +1,16 @@ +import { + ProjectConfigSchema, + type LoadedProjectConfig, + type ProjectConfig, + type ProjectEnvironment, +} from "@supabase/config"; import type { StackConfig, VersionManifest } from "@supabase/stack/effect"; +import { Data, Effect, Schema } from "effect"; +import { legacyParseGoDuration } from "../../shared/config/go-duration.ts"; +import { + flattenLocalStackConfigParity, + type LocalStackConfigParityDecision, +} from "./local-stack-config-parity.ts"; export const excludedStackServices = [ "auth", @@ -18,7 +30,163 @@ export type ExcludedStackService = (typeof excludedStackServices)[number]; 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({}); + +type LocalStackReadinessIntent = + | { readonly mode: "finite"; readonly timeoutMs: number } + | { readonly mode: "infinite" }; + +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; + /** Interactive diagnostics may opt out of deadlines; ordinary starts are finite. */ + readonly readiness?: "finite" | "infinite"; +} + +export interface LocalStackWarning { + readonly code: "unsupported" | "deprecated"; + readonly paths: ReadonlyArray; + readonly message: string; +} + +export interface LocalStackUnsupportedConfig { + readonly path: string; + readonly rationale: string; +} + +interface ResolvedLocalStackLaunch { + readonly stackConfig: StackConfig; + readonly projectPaths: LocalStackProjectPaths; + readonly readiness: LocalStackReadinessIntent; + readonly postgresStartupTimeoutMs: number; + readonly warnings: ReadonlyArray; + /** + * Explicit unsupported fields are retained as structured diagnostics during + * the staged parity migration. A vertical slice promotes its fields to + * mapped behavior and enforcement together; ordinary generated configs are + * not rejected merely because later slices have not landed yet. + */ + readonly unsupported: ReadonlyArray; +} + +export class LocalStackConfigError extends Data.TaggedError("LocalStackConfigError")<{ + readonly detail: string; + readonly suggestion: string; +}> {} + +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 = "", +): ReadonlyArray { + const [segment, ...rest] = segments; + if (segment === undefined) { + return [{ path: prefix, value: root }]; + } + if (!isRecord(root)) { + return []; + } + + if (segment === "*") { + return Object.entries(root).flatMap(([key, value]) => + expandPresentValues(value, rest, prefix === "" ? key : `${prefix}.${key}`), + ); + } + + if (!(segment in root)) { + return []; + } + return expandPresentValues(root[segment], rest, prefix === "" ? segment : `${prefix}.${segment}`); +} + +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; +} + +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>; +}): ReadonlyArray { + return flattenLocalStackConfigParity().flatMap(({ path, decision }) => { + const source = decision.presence === "raw-document" ? input.rawDocument : input.projectConfig; + if (source === undefined) { + return []; + } + return expandPresentValues(source, path.split(".")) + .filter(({ value }) => + decision.presence === "raw-document" ? true : hasMeaningfulDecodedValue(value), + ) + .map(({ path: explicitPath }) => ({ path: explicitPath, decision })); + }); +} + +function diagnosticsFor(input: { + readonly projectConfig: ProjectConfig; + readonly rawDocument?: Readonly>; +}): { + readonly warnings: ReadonlyArray; + readonly unsupported: ReadonlyArray; +} { + const entries = explicitLocalStackConfigEntries(input); + const warningPaths = entries + .filter(({ decision }) => decision._tag === "unsupported-warning") + .map(({ path }) => path) + .sort(); + const unsupported = entries.flatMap(({ path, decision }) => + decision._tag === "unsupported-blocking" ? [{ path, rationale: decision.rationale }] : [], + ); + + return { + warnings: + warningPaths.length === 0 + ? [] + : [ + { + code: "unsupported", + paths: warningPaths, + message: `The next local stack does not yet apply these experimental settings: ${warningPaths.join(", ")}.`, + }, + ], + unsupported, + }; +} + +export function baseStackConfig( exclude: ReadonlyArray, mode: StartMode, ): StackConfig { @@ -40,7 +208,7 @@ export function toStartStackConfig( }; } -export function withServiceVersions( +function withServiceVersions( stackConfig: StackConfig, versions: Partial, ): StackConfig { @@ -96,3 +264,104 @@ export function withServiceVersions( : { ...stackConfig.pooler, version: versions.pooler }, }; } + +export function resolveStoredStackLaunch(input: { + readonly exclude: ReadonlyArray; + readonly mode: StartMode; + readonly runtimeVersions: Partial; +}): StackConfig { + return withServiceVersions(baseStackConfig(input.exclude, input.mode), 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: (cause) => + new LocalStackConfigError({ + detail: `Invalid db.health_timeout '${configured}': ${cause instanceof Error ? cause.message : String(cause)}`, + suggestion: "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: LocalStackReadinessIntent = + input.readiness === "infinite" + ? { mode: "infinite" } + : { + mode: "finite", + timeoutMs: postgresStartupTimeoutMs + LEGACY_NON_DATABASE_READINESS_BUDGET_MS, + }; + const { autoExposeNewTables, deprecationWarning } = resolveAutoExposeNewTables( + projectConfig.api.auto_expose_new_tables, + ); + const diagnostics = diagnosticsFor({ + projectConfig, + rawDocument: input.loadedProjectConfig?.document, + }); + const versionedConfig = resolveStoredStackLaunch({ + exclude: input.exclude, + mode: input.mode, + runtimeVersions: input.runtimeVersions, + }); + + return { + stackConfig: { + ...versionedConfig, + projectDir: input.projectPaths.projectRoot, + postgres: { ...versionedConfig.postgres, autoExposeNewTables }, + }, + projectPaths: input.projectPaths, + readiness, + postgresStartupTimeoutMs, + warnings: + deprecationWarning === undefined + ? diagnostics.warnings + : [ + ...diagnostics.warnings, + { + code: "deprecated", + paths: ["api.auto_expose_new_tables"], + message: deprecationWarning, + }, + ], + unsupported: diagnostics.unsupported, + } 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..1755f5711d 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,183 @@ +import { ProjectConfigSchema, type LoadedProjectConfig } from "@supabase/config"; +import { Effect, Schema } from "effect"; import { describe, expect, it } from "vitest"; -import { toStartStackConfig, withServiceVersions } from "./stack-config.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); + +function loaded(document: Record): LoadedProjectConfig { + return { + path: "/project/supabase/config.toml", + format: "toml", + config: decodeProjectConfig(document), + document, + ignoredPaths: [], + }; +} + +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, }); + }); +}); - expect( - withServiceVersions(toStartStackConfig(["auth", "storage"], "auto"), { - postgres: "17.6.1.090", - auth: "2.187.0", - storage: "1.39.2", +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"); + }); +}); + +describe("resolveLocalStackLaunch", () => { + it("composes project config, paths, flags, versions, and finite readiness", async () => { + const result = await Effect.runPromise( + resolveLocalStackLaunch({ + ...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" }, }), - ).toMatchObject({ - postgres: { version: "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.postgresStartupTimeoutMs).toBe(120_000); + expect(result.readiness).toEqual({ mode: "finite", timeoutMs: 150_000 }); + expect(result.warnings.map(({ code }) => code)).toEqual(["unsupported", "deprecated"]); + expect(result.unsupported.map(({ path }) => path)).toContain("db.health_timeout"); + }); + + it("uses the resolved project environment for the database health timeout", async () => { + const result = await Effect.runPromise( + resolveLocalStackLaunch({ + ...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.postgresStartupTimeoutMs).toBe(5_000); + expect(result.readiness).toEqual({ mode: "finite", timeoutMs: 35_000 }); + }); + + it("supports an explicit infinite debugging policy while retaining startup health", async () => { + const result = await Effect.runPromise( + resolveLocalStackLaunch({ ...baseLaunchInput, readiness: "infinite" }), + ); + + expect(result.postgresStartupTimeoutMs).toBe(120_000); + expect(result.readiness).toEqual({ mode: "infinite" }); + }); + + it("fails before stack construction when the health timeout is invalid", async () => { + const exit = await Effect.runPromise( + resolveLocalStackLaunch({ + ...baseLaunchInput, + loadedProjectConfig: loaded({ db: { health_timeout: "-1s" } }), + }).pipe(Effect.exit), + ); + + expect(exit._tag).toBe("Failure"); }); }); 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", () => { From 311416cdfb41ed5b3ad04301ffe0468caf9110e6 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 15:40:30 +0200 Subject: [PATCH 03/26] refactor(cli): enforce local stack launch policy --- .../next/config/local-stack-config-parity.ts | 10 ++- .../local-stack-config-parity.unit.test.ts | 5 +- .../config/stack-config.integration.test.ts | 6 +- apps/cli/src/next/config/stack-config.ts | 55 +++++++------- .../src/next/config/stack-config.unit.test.ts | 47 ++++++++++-- packages/stack/README.md | 13 ++-- packages/stack/docs/architecture.md | 5 ++ packages/stack/src/StackBuilder.ts | 2 + packages/stack/src/StackConfig.ts | 8 ++ packages/stack/src/StackConfigResolver.ts | 1 + packages/stack/src/createStack.unit.test.ts | 7 ++ packages/stack/src/services/health-budgets.ts | 35 +++++++-- .../src/services/health-budgets.unit.test.ts | 36 ++++++++- packages/stack/src/services/postgres.ts | 25 +++++-- .../stack/src/services/services.unit.test.ts | 74 +++++++++++++++++++ 15 files changed, 267 insertions(+), 62 deletions(-) diff --git a/apps/cli/src/next/config/local-stack-config-parity.ts b/apps/cli/src/next/config/local-stack-config-parity.ts index 516bd0a2b8..f45358fa80 100644 --- a/apps/cli/src/next/config/local-stack-config-parity.ts +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -67,6 +67,14 @@ const mappedAutoExposeNewTables: LocalStackConfigParityDecision = { "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 mappedFunctionManifest: LocalStackConfigParityDecision = { _tag: "mapped", presence: "raw-document", @@ -391,7 +399,7 @@ const localStackConfigParity = { db: { port: unsupportedRuntimeField, shadow_port: commandOnlyDatabaseField, - health_timeout: unsupportedRuntimeField, + health_timeout: mappedDatabaseHealthTimeout, major_version: unsupportedRuntimeField, pooler: { enabled: unsupportedRuntimeField, 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 index 69a75e96a4..5019c21dbe 100644 --- 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 @@ -17,9 +17,9 @@ describe("localStackConfigParity", () => { ]), ), ).toEqual({ - mapped: 11, + mapped: 12, "not-applicable": 9, - "unsupported-blocking": 335, + "unsupported-blocking": 334, "unsupported-warning": 6, }); }); @@ -32,6 +32,7 @@ describe("localStackConfigParity", () => { .sort(), ).toEqual([ "api.auto_expose_new_tables", + "db.health_timeout", "edge_runtime.enabled", "edge_runtime.inspector_port", "edge_runtime.policy", diff --git a/apps/cli/src/next/config/stack-config.integration.test.ts b/apps/cli/src/next/config/stack-config.integration.test.ts index a6681650ab..c86abdb37e 100644 --- a/apps/cli/src/next/config/stack-config.integration.test.ts +++ b/apps/cli/src/next/config/stack-config.integration.test.ts @@ -16,8 +16,6 @@ describe("local stack launch config", () => { await writeFile( join(supabaseDir, "config.toml"), [ - 'project_id = "launch-test"', - "", "[api]", "auto_expose_new_tables = false", "", @@ -55,8 +53,8 @@ describe("local stack launch config", () => { studio: false, postgres: { version: "17.6.1.090", autoExposeNewTables: false }, }); - expect(result.postgresStartupTimeoutMs).toBe(7_000); - expect(result.readiness).toEqual({ mode: "finite", timeoutMs: 37_000 }); + 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", diff --git a/apps/cli/src/next/config/stack-config.ts b/apps/cli/src/next/config/stack-config.ts index 334e926eb8..773259021e 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -4,7 +4,7 @@ import { type ProjectConfig, type ProjectEnvironment, } from "@supabase/config"; -import type { StackConfig, VersionManifest } from "@supabase/stack/effect"; +import type { ReadinessPolicy, StackConfig, VersionManifest } from "@supabase/stack/effect"; import { Data, Effect, Schema } from "effect"; import { legacyParseGoDuration } from "../../shared/config/go-duration.ts"; import { @@ -34,10 +34,6 @@ const LEGACY_NON_DATABASE_READINESS_BUDGET_MS = 30_000; const decodeDefaultProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); const defaultProjectConfig = decodeDefaultProjectConfig({}); -type LocalStackReadinessIntent = - | { readonly mode: "finite"; readonly timeoutMs: number } - | { readonly mode: "infinite" }; - interface LocalStackProjectPaths { readonly projectRoot: string; readonly projectStateRoot: string; @@ -60,29 +56,16 @@ export interface LocalStackWarning { readonly message: string; } -export interface LocalStackUnsupportedConfig { - readonly path: string; - readonly rationale: string; -} - interface ResolvedLocalStackLaunch { readonly stackConfig: StackConfig; readonly projectPaths: LocalStackProjectPaths; - readonly readiness: LocalStackReadinessIntent; - readonly postgresStartupTimeoutMs: number; readonly warnings: ReadonlyArray; - /** - * Explicit unsupported fields are retained as structured diagnostics during - * the staged parity migration. A vertical slice promotes its fields to - * mapped behavior and enforcement together; ordinary generated configs are - * not rejected merely because later slices have not landed yet. - */ - readonly unsupported: ReadonlyArray; } export class LocalStackConfigError extends Data.TaggedError("LocalStackConfigError")<{ readonly detail: string; readonly suggestion: string; + readonly paths: ReadonlyArray; }> {} interface PresentConfigValue { @@ -160,16 +143,17 @@ function diagnosticsFor(input: { readonly rawDocument?: Readonly>; }): { readonly warnings: ReadonlyArray; - readonly unsupported: ReadonlyArray; + readonly blockingPaths: ReadonlyArray; } { const entries = explicitLocalStackConfigEntries(input); const warningPaths = entries .filter(({ decision }) => decision._tag === "unsupported-warning") .map(({ path }) => path) .sort(); - const unsupported = entries.flatMap(({ path, decision }) => - decision._tag === "unsupported-blocking" ? [{ path, rationale: decision.rationale }] : [], - ); + const blockingPaths = entries + .filter(({ decision }) => decision._tag === "unsupported-blocking") + .map(({ path }) => path) + .sort(); return { warnings: @@ -182,7 +166,7 @@ function diagnosticsFor(input: { message: `The next local stack does not yet apply these experimental settings: ${warningPaths.join(", ")}.`, }, ], - unsupported, + blockingPaths, }; } @@ -299,6 +283,7 @@ function resolvePostgresStartupTimeout(input: { new LocalStackConfigError({ detail: `Invalid db.health_timeout '${configured}': ${cause instanceof Error ? cause.message : String(cause)}`, suggestion: "Use a non-negative Go duration such as 2m or 30s.", + paths: ["db.health_timeout"], }), }); } @@ -309,7 +294,7 @@ export const resolveLocalStackLaunch = Effect.fnUntraced(function* (input: Local projectConfig, projectEnvironment: input.projectEnvironment, }); - const readiness: LocalStackReadinessIntent = + const readiness: ReadinessPolicy = input.readiness === "infinite" ? { mode: "infinite" } : { @@ -323,6 +308,16 @@ export const resolveLocalStackLaunch = Effect.fnUntraced(function* (input: Local projectConfig, rawDocument: input.loadedProjectConfig?.document, }); + 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, @@ -333,11 +328,14 @@ export const resolveLocalStackLaunch = Effect.fnUntraced(function* (input: Local stackConfig: { ...versionedConfig, projectDir: input.projectPaths.projectRoot, - postgres: { ...versionedConfig.postgres, autoExposeNewTables }, + readiness, + postgres: { + ...versionedConfig.postgres, + autoExposeNewTables, + startupHealthTimeoutMs: postgresStartupTimeoutMs, + }, }, projectPaths: input.projectPaths, - readiness, - postgresStartupTimeoutMs, warnings: deprecationWarning === undefined ? diagnostics.warnings @@ -349,7 +347,6 @@ export const resolveLocalStackLaunch = Effect.fnUntraced(function* (input: Local message: deprecationWarning, }, ], - unsupported: diagnostics.unsupported, } satisfies ResolvedLocalStackLaunch; }); 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 1755f5711d..7c609f0b81 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -132,10 +132,9 @@ describe("resolveLocalStackLaunch", () => { postgres: { autoExposeNewTables: true, version: "17.6.1.090" }, }); expect(result.projectPaths.projectStateRoot).toBe("/project/.supabase"); - expect(result.postgresStartupTimeoutMs).toBe(120_000); - expect(result.readiness).toEqual({ mode: "finite", timeoutMs: 150_000 }); + 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", "deprecated"]); - expect(result.unsupported.map(({ path }) => path)).toContain("db.health_timeout"); }); it("uses the resolved project environment for the database health timeout", async () => { @@ -157,8 +156,8 @@ describe("resolveLocalStackLaunch", () => { }), ); - expect(result.postgresStartupTimeoutMs).toBe(5_000); - expect(result.readiness).toEqual({ mode: "finite", timeoutMs: 35_000 }); + 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 () => { @@ -166,8 +165,8 @@ describe("resolveLocalStackLaunch", () => { resolveLocalStackLaunch({ ...baseLaunchInput, readiness: "infinite" }), ); - expect(result.postgresStartupTimeoutMs).toBe(120_000); - expect(result.readiness).toEqual({ mode: "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 () => { @@ -180,4 +179,38 @@ describe("resolveLocalStackLaunch", () => { expect(exit._tag).toBe("Failure"); }); + + it("fails on explicit blocking fields and reports paths without values", async () => { + const exit = await Effect.runPromise( + resolveLocalStackLaunch({ + ...baseLaunchInput, + loadedProjectConfig: loaded({ + auth: { jwt_secret: "do-not-leak" }, + storage: { file_size_limit: "another-private-value" }, + }), + }).pipe(Effect.exit), + ); + + expect(exit._tag).toBe("Failure"); + expect(JSON.stringify(exit)).toContain("auth.jwt_secret"); + expect(JSON.stringify(exit)).toContain("storage.file_size_limit"); + expect(JSON.stringify(exit)).not.toContain("do-not-leak"); + expect(JSON.stringify(exit)).not.toContain("another-private-value"); + }); + + it("warns on explicit warning fields using paths only", async () => { + const result = await Effect.runPromise( + resolveLocalStackLaunch({ + ...baseLaunchInput, + loadedProjectConfig: loaded({ + experimental: { s3_secret_key: "do-not-leak" }, + }), + }), + ); + + expect(result.warnings).toEqual([ + expect.objectContaining({ code: "unsupported", paths: ["experimental.s3_secret_key"] }), + ]); + expect(JSON.stringify(result.warnings)).not.toContain("do-not-leak"); + }); }); diff --git a/packages/stack/README.md b/packages/stack/README.md index 321d60d988..17299760b4 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -80,6 +80,7 @@ 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. | +| `readiness` | finite or infinite policy | No | `120s` | Stack-wide readiness deadline. Per-call readiness options take precedence. | | `jwtSecret` | `string` | No | | Secret for JWT signing (min 32 characters). Defaults to a well-known dev secret | | `port` | `number` | No | | API proxy port (auto-allocated if omitted) | | `publishableKey` | `string` | No | | Custom opaque publishable key | @@ -89,11 +90,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` diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index 4ce734db24..52d0de630c 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -56,6 +56,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 diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index 318ad63989..e4a7e28eed 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -246,6 +246,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), }) @@ -253,6 +254,7 @@ export class StackBuilder extends Context.Service< image: postgresResolution.image, dataDir: config.postgres.dataDir, port: config.dbPort, + startupHealthTimeoutMs: config.postgres.startupHealthTimeoutMs, networkArgs: dockerNetworkArgs(platform.os, [config.dbPort]), jwtSecret: config.jwtSecret, jwtExpiry: config.auth !== false ? config.auth.jwtExpiry : 3600, diff --git a/packages/stack/src/StackConfig.ts b/packages/stack/src/StackConfig.ts index 32d1251225..579801b9c6 100644 --- a/packages/stack/src/StackConfig.ts +++ b/packages/stack/src/StackConfig.ts @@ -46,6 +46,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`, @@ -179,6 +186,7 @@ export interface ResolvedPostgresConfig { readonly port: number; readonly dataDir: string; readonly version: string; + readonly startupHealthTimeoutMs?: number; readonly autoExposeNewTables: boolean; } diff --git a/packages/stack/src/StackConfigResolver.ts b/packages/stack/src/StackConfigResolver.ts index 88cd888abf..f9c68495e5 100644 --- a/packages/stack/src/StackConfigResolver.ts +++ b/packages/stack/src/StackConfigResolver.ts @@ -521,6 +521,7 @@ export async function resolveConfig( 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/createStack.unit.test.ts b/packages/stack/src/createStack.unit.test.ts index 747b16686b..ab2b9d4ab8 100644 --- a/packages/stack/src/createStack.unit.test.ts +++ b/packages/stack/src/createStack.unit.test.ts @@ -269,3 +269,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/services/health-budgets.ts b/packages/stack/src/services/health-budgets.ts index ea478b5ad7..b5fb245464 100644 --- a/packages/stack/src/services/health-budgets.ts +++ b/packages/stack/src/services/health-budgets.ts @@ -1,9 +1,34 @@ -import type { HealthCheckConfig } from "@supabase/process-compose"; +export interface HealthBudget { + readonly initialDelaySeconds: number; + readonly periodSeconds: number; + readonly startupFailureThreshold: number; + readonly failureThreshold: number; +} -type HealthBudget = Pick< - HealthCheckConfig, - "initialDelaySeconds" | "periodSeconds" | "startupFailureThreshold" | "failureThreshold" ->; +/** + * 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 9dced20c60..6eb372a92a 100644 --- a/packages/stack/src/services/health-budgets.unit.test.ts +++ b/packages/stack/src/services/health-budgets.unit.test.ts @@ -1,7 +1,41 @@ import { describe, expect, it } from "vitest"; -import { stackHealthBudgets } from "./health-budgets.ts"; +import { stackHealthBudgets, 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/postgres.ts b/packages/stack/src/services/postgres.ts index 6968f989f8..cff3f28de9 100644 --- a/packages/stack/src/services/postgres.ts +++ b/packages/stack/src/services/postgres.ts @@ -5,11 +5,12 @@ import { dockerServiceOrphanCleanup, removePathOnOrphanCleanup, } from "./docker-cleanup.ts"; -import { stackHealthBudgets } from "./health-budgets.ts"; +import { stackHealthBudgets, withStartupHealthTimeout } from "./health-budgets.ts"; interface PostgresServiceOptions { readonly dataDir: string; readonly port: number; + readonly startupHealthTimeoutMs?: number; readonly cleanupDataDirOnExit?: boolean; } @@ -80,7 +81,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`, @@ -90,7 +95,7 @@ const postgresHealthCheck = (binPath: string, port: number) => ({ LD_LIBRARY_PATH: `${binPath}/lib`, }, }, - ...stackHealthBudgets.postgresNative, + ...withStartupHealthTimeout(stackHealthBudgets.postgresNative, startupHealthTimeoutMs), }); /** @@ -101,13 +106,17 @@ 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, +) => ({ probe: { _tag: "Exec" as const, command: "docker", args: ["exec", containerName, "pg_isready", "-p", String(port), "-U", "postgres"], }, - ...stackHealthBudgets.postgresDocker, + ...withStartupHealthTimeout(stackHealthBudgets.postgresDocker, startupHealthTimeoutMs), }); export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => { @@ -146,7 +155,7 @@ export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => `hba_file=${customHbaPath}`, ], env: postgresEnv(opts), - healthCheck: postgresHealthCheck(opts.binPath, opts.port), + healthCheck: postgresHealthCheck(opts.binPath, opts.port, opts.startupHealthTimeoutMs), shutdown: { signal: "SIGTERM", timeoutSeconds: 10 }, supervision: { orphanCleanup: [ @@ -163,7 +172,7 @@ export const makePostgresService = (opts: NativePostgresOptions): ServiceDef => command: "bash", args: [initScript, "-p", String(opts.port), ...NATIVE_POSTGRES_RUNTIME_ARGS], env: postgresEnv(opts), - 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", @@ -193,7 +202,7 @@ export const makePostgresServiceDocker = (opts: DockerPostgresOptions): ServiceD name: "postgres", command: "docker", args: dockerArgs, - healthCheck: postgresDockerHealthCheck(containerName, opts.port), + healthCheck: postgresDockerHealthCheck(containerName, opts.port, opts.startupHealthTimeoutMs), shutdown: { signal: "SIGTERM", timeoutSeconds: 10 }, cleanup: dockerServiceCleanup(containerName), supervision: { diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index 2db96bb9af..b4208333a2 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -65,6 +65,37 @@ 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, + }); + + 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, + }); + + expect(def.healthCheck).toMatchObject({ + initialDelaySeconds: 0, + startupFailureThreshold: 1, + failureThreshold: 30, + }); + } + }); }); describe("analyticsDockerRuntimeNetwork", () => { @@ -204,6 +235,49 @@ 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, + networkArgs: [], + jwtSecret: "test-jwt-secret-with-at-least-32-characters", + jwtExpiry: 3600, + apiPort: API_PORT, + startupHealthTimeoutMs: 120_000, + }); + + 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, + networkArgs: [], + jwtSecret: "test-jwt-secret-with-at-least-32-characters", + jwtExpiry: 3600, + apiPort: API_PORT, + startupHealthTimeoutMs, + }); + + 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), From 5bb3ac56ced9b3b499a9c3a3cded48d094365552 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 16:16:41 +0200 Subject: [PATCH 04/26] refactor(cli): map local stack core topology --- apps/cli/src/next/config/core-stack-config.ts | 380 ++++++++++++++++++ .../next/config/local-stack-config-parity.ts | 83 ++-- .../local-stack-config-parity.unit.test.ts | 37 +- apps/cli/src/next/config/stack-config.ts | 77 ++-- .../src/next/config/stack-config.unit.test.ts | 110 ++++- packages/stack/src/LocalStack.ts | 8 +- packages/stack/src/Platform.ts | 6 +- packages/stack/src/Stack.unit.test.ts | 5 +- packages/stack/src/StackBuilder.ts | 38 +- packages/stack/src/StackBuilder.unit.test.ts | 59 +++ packages/stack/src/StackConfig.ts | 13 +- packages/stack/src/StackConfigResolver.ts | 11 +- packages/stack/src/StackMetadata.ts | 1 + packages/stack/src/services/mailpit.ts | 18 +- .../stack/src/services/services.unit.test.ts | 4 +- 15 files changed, 746 insertions(+), 104 deletions(-) create mode 100644 apps/cli/src/next/config/core-stack-config.ts 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..0df13cdead --- /dev/null +++ b/apps/cli/src/next/config/core-stack-config.ts @@ -0,0 +1,380 @@ +import type { ProjectConfig, ProjectEnvironment } from "@supabase/config"; +import type { StackConfig } from "@supabase/stack/effect"; +import { Data } from "effect"; + +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; +}> {} + +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 invalidLocalStackConfig(path: string, suggestion: string): LocalStackConfigError { + return new LocalStackConfigError({ + detail: `Invalid local stack configuration at ${path}.`, + suggestion, + paths: [path], + }); +} + +function environmentOverride( + name: string, + configured: string | undefined, + environment: ProjectEnvironment | null, +): string | undefined { + const value = environment?.values[name]; + if (value === undefined || value.length === 0) return configured; + 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 environment: ProjectEnvironment | null; + readonly envName: string; + readonly configured: boolean; + readonly path: string; +}): boolean { + const override = environmentOverride(input.envName, undefined, input.environment); + if (override === undefined) return input.configured; + const resolved = GO_BOOLEAN_VALUES[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 environment: ProjectEnvironment | null; + readonly envName: string; + readonly configured: number | undefined; + readonly path: string; + readonly required?: boolean; +}): number | undefined { + const override = environmentOverride(input.envName, undefined, input.environment); + 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 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 envName: string; + readonly configured: boolean; + readonly path: string; + readonly excludedAs: ExcludedStackService; + }) => + resolveBoolean({ + environment: projectEnvironment, + envName: params.envName, + configured: params.configured, + path: params.path, + }) && !excluded.has(params.excludedAs); + + const apiEnabled = enabled({ + envName: "SUPABASE_API_ENABLED", + configured: projectConfig.api.enabled, + path: "api.enabled", + excludedAs: "postgrest", + }); + const authEnabled = enabled({ + envName: "SUPABASE_AUTH_ENABLED", + configured: projectConfig.auth.enabled, + path: "auth.enabled", + excludedAs: "auth", + }); + const realtimeEnabled = enabled({ + envName: "SUPABASE_REALTIME_ENABLED", + configured: projectConfig.realtime.enabled, + path: "realtime.enabled", + excludedAs: "realtime", + }); + const storageEnabled = enabled({ + envName: "SUPABASE_STORAGE_ENABLED", + configured: projectConfig.storage.enabled, + path: "storage.enabled", + excludedAs: "storage", + }); + const mailpitEnabled = enabled({ + envName: "SUPABASE_LOCAL_SMTP_ENABLED", + configured: projectConfig.local_smtp.enabled, + path: "local_smtp.enabled", + excludedAs: "mailpit", + }); + const studioEnabled = enabled({ + envName: "SUPABASE_STUDIO_ENABLED", + configured: projectConfig.studio.enabled, + path: "studio.enabled", + excludedAs: "studio", + }); + const analyticsEnabled = enabled({ + envName: "SUPABASE_ANALYTICS_ENABLED", + configured: projectConfig.analytics.enabled, + path: "analytics.enabled", + excludedAs: "analytics", + }); + const poolerEnabled = enabled({ + envName: "SUPABASE_DB_POOLER_ENABLED", + configured: projectConfig.db.pooler.enabled, + path: "db.pooler.enabled", + excludedAs: "pooler", + }); + const edgeRuntimeEnabled = enabled({ + envName: "SUPABASE_EDGE_RUNTIME_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({ + environment: projectEnvironment, + envName: "SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED", + configured: projectConfig.storage.image_transformation?.enabled ?? false, + path: "storage.image_transformation.enabled", + }); + + const apiPort = resolvePort({ + environment: projectEnvironment, + envName: "SUPABASE_API_PORT", + configured: projectConfig.api.port, + path: "api.port", + required: apiEnabled, + }); + const dbPort = resolvePort({ + environment: projectEnvironment, + envName: "SUPABASE_DB_PORT", + configured: projectConfig.db.port, + path: "db.port", + required: true, + }); + const studioPort = resolvePort({ + environment: projectEnvironment, + envName: "SUPABASE_STUDIO_PORT", + configured: projectConfig.studio.port, + path: "studio.port", + required: studioEnabled, + }); + const mailpitPort = resolvePort({ + environment: projectEnvironment, + envName: "SUPABASE_LOCAL_SMTP_PORT", + configured: projectConfig.local_smtp.port, + path: "local_smtp.port", + required: mailpitEnabled, + }); + const mailpitSmtpPort = resolvePort({ + environment: projectEnvironment, + envName: "SUPABASE_LOCAL_SMTP_SMTP_PORT", + configured: projectConfig.local_smtp.smtp_port, + path: "local_smtp.smtp_port", + }); + const mailpitPop3Port = resolvePort({ + environment: projectEnvironment, + envName: "SUPABASE_LOCAL_SMTP_POP3_PORT", + configured: projectConfig.local_smtp.pop3_port, + path: "local_smtp.pop3_port", + }); + const analyticsPort = resolvePort({ + environment: projectEnvironment, + envName: "SUPABASE_ANALYTICS_PORT", + configured: projectConfig.analytics.port, + path: "analytics.port", + required: analyticsEnabled, + }); + const poolerPort = resolvePort({ + environment: projectEnvironment, + envName: "SUPABASE_DB_POOLER_PORT", + configured: projectConfig.db.pooler.port, + path: "db.pooler.port", + required: poolerEnabled, + }); + const edgeRuntimeInspectorPort = resolvePort({ + environment: projectEnvironment, + envName: "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", + configured: projectConfig.edge_runtime.inspector_port, + path: "edge_runtime.inspector_port", + }); + + return { + ...input.base, + port: apiPort, + postgres: serviceConfig(input.base.postgres, { port: dbPort }), + postgrest: apiEnabled + ? serviceConfig(input.base.postgrest, { + schemas: projectConfig.api.schemas, + extraSearchPath: projectConfig.api.extra_search_path, + maxRows: projectConfig.api.max_rows, + }) + : false, + auth: authEnabled ? serviceConfig(input.base.auth, {}) : false, + edgeRuntime: edgeRuntimeEnabled + ? serviceConfig(input.base.edgeRuntime, { + policy: resolveEdgeRuntimePolicy(projectConfig.edge_runtime.policy), + inspectorPort: edgeRuntimeInspectorPort, + }) + : 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 ? {} : { smtpPort: mailpitSmtpPort }), + ...(mailpitPop3Port === undefined ? {} : { pop3Port: mailpitPop3Port }), + adminEmail: environmentOverride( + "SUPABASE_LOCAL_SMTP_ADMIN_EMAIL", + projectConfig.local_smtp.admin_email, + projectEnvironment, + ), + senderName: environmentOverride( + "SUPABASE_LOCAL_SMTP_SENDER_NAME", + projectConfig.local_smtp.sender_name, + projectEnvironment, + ), + }) + : false, + pgmeta: studioEnabled && !excluded.has("pgmeta") ? input.base.pgmeta : false, + studio: + studioEnabled && !excluded.has("pgmeta") + ? serviceConfig(input.base.studio, { + port: studioPort, + apiUrl: + environmentOverride( + "SUPABASE_STUDIO_API_URL", + projectConfig.studio.api_url, + projectEnvironment, + ) ?? 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/local-stack-config-parity.ts b/apps/cli/src/next/config/local-stack-config-parity.ts index f45358fa80..8dee4cb21b 100644 --- a/apps/cli/src/next/config/local-stack-config-parity.ts +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -75,6 +75,21 @@ const mappedDatabaseHealthTimeout: LocalStackConfigParityDecision = { "The launch Adapter resolves the legacy environment override, applies the duration to PostgreSQL startup health, and derives the stack readiness deadline from it.", }; +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 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", @@ -215,7 +230,7 @@ const authSmsParity = { } satisfies Record; const authParity = { - enabled: unsupportedRuntimeField, + enabled: mappedCoreTopologyField, site_url: unsupportedRuntimeField, additional_redirect_urls: unsupportedRuntimeField, jwt_expiry: unsupportedRuntimeField, @@ -371,22 +386,22 @@ const dbSettingsParity = { * classified at the record field itself. */ const localStackConfigParity = { - project_id: unsupportedOptionalRuntimeField, + project_id: projectIdentityField, analytics: { - enabled: unsupportedRuntimeField, - port: unsupportedRuntimeField, - backend: unsupportedRuntimeField, + enabled: mappedCoreTopologyField, + port: mappedCoreTopologyField, + backend: mappedCoreTopologyField, vector_port: unsupportedOptionalRuntimeField, gcp_project_id: unsupportedOptionalRuntimeField, gcp_project_number: unsupportedOptionalRuntimeField, gcp_jwt_path: unsupportedOptionalRuntimeField, } satisfies Record, api: { - enabled: unsupportedRuntimeField, - port: unsupportedRuntimeField, - schemas: unsupportedRuntimeField, - extra_search_path: unsupportedRuntimeField, - max_rows: unsupportedRuntimeField, + enabled: mappedCoreTopologyField, + port: mappedCoreTopologyField, + schemas: mappedCoreTopologyField, + extra_search_path: mappedCoreTopologyField, + max_rows: mappedCoreTopologyField, auto_expose_new_tables: mappedAutoExposeNewTables, tls: { enabled: unsupportedRuntimeField, @@ -397,16 +412,16 @@ const localStackConfigParity = { } satisfies Record, auth: authParity, db: { - port: unsupportedRuntimeField, + port: mappedCoreTopologyField, shadow_port: commandOnlyDatabaseField, health_timeout: mappedDatabaseHealthTimeout, major_version: unsupportedRuntimeField, pooler: { - enabled: unsupportedRuntimeField, - port: unsupportedRuntimeField, - pool_mode: unsupportedRuntimeField, - default_pool_size: unsupportedRuntimeField, - max_client_conn: unsupportedRuntimeField, + enabled: mappedCoreTopologyField, + port: mappedCoreTopologyField, + pool_mode: mappedCoreTopologyField, + default_pool_size: mappedCoreTopologyField, + max_client_conn: mappedCoreTopologyField, } satisfies Record, migrations: { enabled: unsupportedRuntimeField, @@ -428,9 +443,9 @@ const localStackConfigParity = { vault: unsupportedSecretRuntimeField, } satisfies Record, edge_runtime: { - enabled: mappedFunctionsDevEdgeRuntime, - policy: mappedFunctionsDevEdgeRuntime, - inspector_port: mappedFunctionsDevEdgeRuntime, + enabled: mappedCoreTopologyField, + policy: mappedCoreTopologyField, + inspector_port: mappedCoreTopologyField, deno_version: unsupportedRuntimeField, secrets: mappedFunctionsDevEdgeRuntime, } satisfies Record, @@ -438,23 +453,23 @@ const localStackConfigParity = { "*": functionConfigParity, }, local_smtp: { - enabled: unsupportedRuntimeField, - port: unsupportedRuntimeField, - smtp_port: unsupportedOptionalRuntimeField, - pop3_port: unsupportedOptionalRuntimeField, - admin_email: unsupportedOptionalRuntimeField, - sender_name: unsupportedOptionalRuntimeField, + enabled: mappedCoreTopologyField, + port: mappedCoreTopologyField, + smtp_port: mappedCoreTopologyField, + pop3_port: mappedCoreTopologyField, + admin_email: mappedCoreTopologyField, + sender_name: mappedCoreTopologyField, } satisfies Record, realtime: { - enabled: unsupportedRuntimeField, + enabled: mappedCoreTopologyField, ip_version: unsupportedRuntimeField, - max_header_length: unsupportedRuntimeField, + max_header_length: mappedCoreTopologyField, } satisfies Record, storage: { - enabled: unsupportedRuntimeField, - file_size_limit: unsupportedRuntimeField, + enabled: mappedCoreTopologyField, + file_size_limit: mappedCoreTopologyField, image_transformation: { - enabled: unsupportedRuntimeField, + enabled: mappedCoreTopologyField, } satisfies Record, Node>, buckets: { "*": { @@ -465,7 +480,7 @@ const localStackConfigParity = { } satisfies Record[string], Node>, }, s3_protocol: { - enabled: unsupportedRuntimeField, + enabled: mappedCoreTopologyField, } satisfies Record, analytics: { enabled: unsupportedRuntimeField, @@ -482,9 +497,9 @@ const localStackConfigParity = { } satisfies Record, } satisfies Record, studio: { - enabled: unsupportedRuntimeField, - port: unsupportedRuntimeField, - api_url: unsupportedRuntimeField, + enabled: mappedCoreTopologyField, + port: mappedCoreTopologyField, + api_url: mappedCoreTopologyField, openai_api_key: unsupportedSecretRuntimeField, } satisfies Record, experimental: { 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 index 5019c21dbe..597b35cd90 100644 --- 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 @@ -17,9 +17,9 @@ describe("localStackConfigParity", () => { ]), ), ).toEqual({ - mapped: 12, - "not-applicable": 9, - "unsupported-blocking": 334, + mapped: 42, + "not-applicable": 10, + "unsupported-blocking": 303, "unsupported-warning": 6, }); }); @@ -31,8 +31,23 @@ describe("localStackConfigParity", () => { .map(({ path }) => path) .sort(), ).toEqual([ + "analytics.backend", + "analytics.enabled", + "analytics.port", "api.auto_expose_new_tables", + "api.enabled", + "api.extra_search_path", + "api.max_rows", + "api.port", + "api.schemas", + "auth.enabled", "db.health_timeout", + "db.pooler.default_pool_size", + "db.pooler.enabled", + "db.pooler.max_client_conn", + "db.pooler.pool_mode", + "db.pooler.port", + "db.port", "edge_runtime.enabled", "edge_runtime.inspector_port", "edge_runtime.policy", @@ -43,6 +58,21 @@ describe("localStackConfigParity", () => { "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.max_header_length", + "storage.enabled", + "storage.file_size_limit", + "storage.image_transformation.enabled", + "storage.s3_protocol.enabled", + "studio.api_url", + "studio.enabled", + "studio.port", ]); }); @@ -72,6 +102,7 @@ describe("localStackConfigParity", () => { "experimental.pgdelta.declarative_schema_path", "experimental.pgdelta.enabled", "experimental.pgdelta.format_options", + "project_id", "remotes", ]); }); diff --git a/apps/cli/src/next/config/stack-config.ts b/apps/cli/src/next/config/stack-config.ts index 773259021e..a2d468153e 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -5,28 +5,21 @@ import { type ProjectEnvironment, } from "@supabase/config"; import type { ReadinessPolicy, StackConfig, VersionManifest } from "@supabase/stack/effect"; -import { Data, Effect, Schema } from "effect"; +import { Effect, Schema } from "effect"; import { legacyParseGoDuration } from "../../shared/config/go-duration.ts"; +import { + excludedStackServices, + invalidLocalStackConfig, + LocalStackConfigError, + resolveCoreStackConfig, + type ExcludedStackService, +} from "./core-stack-config.ts"; import { flattenLocalStackConfigParity, type LocalStackConfigParityDecision, } from "./local-stack-config-parity.ts"; -export const excludedStackServices = [ - "auth", - "postgrest", - "realtime", - "storage", - "imgproxy", - "mailpit", - "pgmeta", - "studio", - "analytics", - "vector", - "pooler", -] as const; - -export type ExcludedStackService = (typeof excludedStackServices)[number]; +export { excludedStackServices, LocalStackConfigError, type ExcludedStackService }; export const startModes = ["native", "auto", "docker"] as const; export type StartMode = (typeof startModes)[number]; @@ -46,6 +39,8 @@ export interface LocalStackLaunchInput { 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"; } @@ -62,12 +57,6 @@ interface ResolvedLocalStackLaunch { readonly warnings: ReadonlyArray; } -export class LocalStackConfigError extends Data.TaggedError("LocalStackConfigError")<{ - readonly detail: string; - readonly suggestion: string; - readonly paths: ReadonlyArray; -}> {} - interface PresentConfigValue { readonly path: string; readonly value: unknown; @@ -173,11 +162,13 @@ function diagnosticsFor(input: { 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 : {}, @@ -253,8 +244,12 @@ 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.runtimeVersions); + return withServiceVersions( + baseStackConfig(input.exclude, input.mode, input.startupMode), + input.runtimeVersions, + ); } export function resolveFunctionsDevStackLaunch( @@ -279,12 +274,11 @@ function resolvePostgresStartupTimeout(input: { } return postgresStartupTimeoutMs; }, - catch: (cause) => - new LocalStackConfigError({ - detail: `Invalid db.health_timeout '${configured}': ${cause instanceof Error ? cause.message : String(cause)}`, - suggestion: "Use a non-negative Go duration such as 2m or 30s.", - paths: ["db.health_timeout"], - }), + catch: () => + invalidLocalStackConfig( + "db.health_timeout", + "Use a non-negative Go duration such as 2m or 30s.", + ), }); } @@ -322,15 +316,34 @@ export const resolveLocalStackLaunch = Effect.fnUntraced(function* (input: Local exclude: input.exclude, mode: input.mode, runtimeVersions: input.runtimeVersions, + startupMode: input.startupMode, + }); + const coreConfig = yield* Effect.try({ + try: () => + resolveCoreStackConfig({ + 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: [], + }), }); return { stackConfig: { - ...versionedConfig, + ...coreConfig, projectDir: input.projectPaths.projectRoot, readiness, postgres: { - ...versionedConfig.postgres, + ...coreConfig.postgres, autoExposeNewTables, startupHealthTimeoutMs: postgresStartupTimeoutMs, }, 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 7c609f0b81..d969f6c1aa 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -110,6 +110,112 @@ describe("explicitLocalStackConfigEntries", () => { }); describe("resolveLocalStackLaunch", () => { + it("maps API and database topology into the stack interface", async () => { + const result = await Effect.runPromise( + resolveLocalStackLaunch({ + ...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( + resolveLocalStackLaunch({ + ...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("reports malformed topology overrides by path without retaining their value", async () => { + const exit = await Effect.runPromise( + resolveLocalStackLaunch({ + ...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( + resolveLocalStackLaunch({ + ...baseLaunchInput, + loadedProjectConfig: loaded({ local_smtp: { enabled: true, port: 6104 } }), + }), + ); + const explicit = await Effect.runPromise( + resolveLocalStackLaunch({ + ...baseLaunchInput, + loadedProjectConfig: loaded({ + local_smtp: { enabled: true, port: 6104, smtp_port: 6105, pop3_port: 6106 }, + }), + }), + ); + + 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 }), + ); + }); + it("composes project config, paths, flags, versions, and finite readiness", async () => { const result = await Effect.runPromise( resolveLocalStackLaunch({ @@ -186,14 +292,14 @@ describe("resolveLocalStackLaunch", () => { ...baseLaunchInput, loadedProjectConfig: loaded({ auth: { jwt_secret: "do-not-leak" }, - storage: { file_size_limit: "another-private-value" }, + realtime: { ip_version: "IPv6" }, }), }).pipe(Effect.exit), ); expect(exit._tag).toBe("Failure"); expect(JSON.stringify(exit)).toContain("auth.jwt_secret"); - expect(JSON.stringify(exit)).toContain("storage.file_size_limit"); + expect(JSON.stringify(exit)).toContain("realtime.ip_version"); expect(JSON.stringify(exit)).not.toContain("do-not-leak"); expect(JSON.stringify(exit)).not.toContain("another-private-value"); }); diff --git a/packages/stack/src/LocalStack.ts b/packages/stack/src/LocalStack.ts index 23602329cf..ebf7ca66ab 100644 --- a/packages/stack/src/LocalStack.ts +++ b/packages/stack/src/LocalStack.ts @@ -110,8 +110,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}` }), 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/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index f3286e0a53..b4a5b9a71f 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -556,8 +556,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 e4a7e28eed..16fe9d3a53 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -17,7 +17,7 @@ import { makeEdgeRuntimeServiceNative, } from "./services/edge-runtime.ts"; import { makeImgproxyServiceDocker } from "./services/imgproxy.ts"; -import { makeMailpitServiceDocker } from "./services/mailpit.ts"; +import { mailpitContainerPorts, makeMailpitServiceDocker } from "./services/mailpit.ts"; import { makePgmetaServiceDocker } from "./services/pgmeta.ts"; import { makePoolerServiceDocker, poolerContainerPorts } from "./services/pooler.ts"; import { makePostgresInitService } from "./services/postgres-init.ts"; @@ -325,7 +325,7 @@ export class StackBuilder extends Context.Service< jwtExpiry: config.auth.jwtExpiry, externalUrl: config.auth.externalUrl, smtpHost: config.mailpit !== false ? serviceHost : undefined, - smtpPort: config.mailpit !== false ? config.mailpit.smtpPort : undefined, + smtpPort: config.mailpit !== false ? config.mailpit.smtpTransportPort : undefined, smtpAdminEmail: config.mailpit !== false ? config.mailpit.adminEmail : undefined, smtpSenderName: config.mailpit !== false ? config.mailpit.senderName : undefined, dependencies: postgresDeps, @@ -340,7 +340,7 @@ export class StackBuilder extends Context.Service< jwtExpiry: config.auth.jwtExpiry, externalUrl: config.auth.externalUrl, smtpHost: config.mailpit !== false ? serviceHost : undefined, - smtpPort: config.mailpit !== false ? config.mailpit.smtpPort : undefined, + smtpPort: config.mailpit !== false ? config.mailpit.smtpTransportPort : undefined, smtpAdminEmail: config.mailpit !== false ? config.mailpit.adminEmail : undefined, smtpSenderName: config.mailpit !== false ? config.mailpit.senderName : undefined, networkArgs: dockerNetworkArgs(platform.os, [config.auth.port]), @@ -385,13 +385,31 @@ export class StackBuilder extends Context.Service< ...makeMailpitServiceDocker({ image: mailpitImage, apiPort: config.apiPort, - webPort: config.mailpit.port, - smtpPort: config.mailpit.smtpPort, - pop3Port: config.mailpit.pop3Port, - networkArgs: dockerNetworkArgs(platform.os, [ - config.mailpit.port, - config.mailpit.smtpPort, - config.mailpit.pop3Port, + healthPort: config.mailpit.port, + networkArgs: dockerPortMapArgs(platform.os, [ + { host: config.mailpit.port, container: mailpitContainerPorts.web }, + ...(config.mailpit.smtpHostPort === false + ? [ + { + host: config.mailpit.smtpTransportPort, + container: mailpitContainerPorts.smtp, + hostAddress: "127.0.0.1", + }, + ] + : [ + { + host: config.mailpit.smtpHostPort, + container: mailpitContainerPorts.smtp, + }, + ]), + ...(config.mailpit.pop3HostPort === false + ? [] + : [ + { + host: config.mailpit.pop3HostPort, + container: mailpitContainerPorts.pop3, + }, + ]), ]), }), enabled: true, diff --git a/packages/stack/src/StackBuilder.unit.test.ts b/packages/stack/src/StackBuilder.unit.test.ts index 2fb442725c..9f9ce01588 100644 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ b/packages/stack/src/StackBuilder.unit.test.ts @@ -335,6 +335,65 @@ 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("docker mode wires auth directly to postgres readiness", () => { const resolver = mockBinaryResolver(); const layer = builderLayer(resolver); diff --git a/packages/stack/src/StackConfig.ts b/packages/stack/src/StackConfig.ts index 579801b9c6..6b773b43a3 100644 --- a/packages/stack/src/StackConfig.ts +++ b/packages/stack/src/StackConfig.ts @@ -111,8 +111,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; @@ -240,8 +242,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; diff --git a/packages/stack/src/StackConfigResolver.ts b/packages/stack/src/StackConfigResolver.ts index f9c68495e5..d3fca75a41 100644 --- a/packages/stack/src/StackConfigResolver.ts +++ b/packages/stack/src/StackConfigResolver.ts @@ -344,8 +344,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", @@ -478,8 +479,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, 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/services/mailpit.ts b/packages/stack/src/services/mailpit.ts index e60f41a14e..4a401157b0 100644 --- a/packages/stack/src/services/mailpit.ts +++ b/packages/stack/src/services/mailpit.ts @@ -5,12 +5,16 @@ import { stackHealthBudgets } from "./health-budgets.ts"; interface DockerMailpitOptions { readonly image: string; readonly apiPort: number; - readonly webPort: number; - readonly smtpPort: number; - readonly pop3Port: number; + readonly healthPort: number; readonly networkArgs: ReadonlyArray; } +export const mailpitContainerPorts = { + web: 8025, + smtp: 1025, + pop3: 1110, +} as const; + const mailpitHealthCheck = (port: number): ServiceDef["healthCheck"] => hostHttpHealthCheck(port, "/readyz", { ...stackHealthBudgets.mailpit, @@ -23,10 +27,10 @@ export const makeMailpitServiceDocker = (opts: DockerMailpitOptions): ServiceDef image: opts.image, networkArgs: opts.networkArgs, 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), + healthCheck: mailpitHealthCheck(opts.healthPort), }); diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index b4208333a2..d495155178 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -579,9 +579,7 @@ describe("docker-backed auxiliary services", () => { const def = makeMailpitServiceDocker({ image: dockerImageForService("mailpit", DEFAULT_VERSIONS.mailpit), apiPort: API_PORT, - webPort: 54323, - smtpPort: 54324, - pop3Port: 54325, + healthPort: 54323, networkArgs: [ ...LINUX_HOST_GATEWAY_ARGS, "-p", From c848abaea28813c69056602cd78bd3abd703e611 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 16:17:18 +0200 Subject: [PATCH 05/26] feat(stack): implement local auth config parity --- apps/cli/src/next/config/auth-stack-config.ts | 643 ++++++++++++++++++ .../config/auth-stack-config.unit.test.ts | 281 ++++++++ .../next/config/local-stack-config-parity.ts | 150 ++-- .../local-stack-config-parity.unit.test.ts | 36 +- .../config/stack-config.integration.test.ts | 91 +++ apps/cli/src/next/config/stack-config.ts | 20 + .../src/next/config/stack-config.unit.test.ts | 8 +- packages/stack/README.md | 45 +- packages/stack/docs/architecture.md | 17 +- packages/stack/src/AuthConfig.ts | 134 ++++ packages/stack/src/LocalCredentials.ts | 242 +++++++ .../stack/src/LocalCredentials.unit.test.ts | 143 ++++ packages/stack/src/Stack.unit.test.ts | 33 +- packages/stack/src/StackBuilder.ts | 39 +- packages/stack/src/StackBuilder.unit.test.ts | 31 + packages/stack/src/StackConfig.ts | 21 +- packages/stack/src/StackConfigResolver.ts | 60 +- packages/stack/src/effect.ts | 21 + packages/stack/src/errors.ts | 11 + packages/stack/src/services/auth.ts | 215 ++++-- .../stack/src/services/services.unit.test.ts | 134 +++- 21 files changed, 2191 insertions(+), 184 deletions(-) create mode 100644 apps/cli/src/next/config/auth-stack-config.ts create mode 100644 apps/cli/src/next/config/auth-stack-config.unit.test.ts create mode 100644 packages/stack/src/AuthConfig.ts create mode 100644 packages/stack/src/LocalCredentials.ts create mode 100644 packages/stack/src/LocalCredentials.unit.test.ts 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..ce2f76657b --- /dev/null +++ b/apps/cli/src/next/config/auth-stack-config.ts @@ -0,0 +1,643 @@ +import type { 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"; + +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 environmentOverride( + environment: ProjectEnvironment | null, + name: string, + configured: string | undefined, +): string | undefined { + const value = environment?.values[name]; + if (value === undefined || value.length === 0) return configured; + 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 invalidOverride(path: string, suggestion: string): AuthStackConfigError { + return new AuthStackConfigError({ + path, + detail: `Invalid Auth environment override at ${path}.`, + suggestion, + }); +} + +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, +}; + +function envBoolean(input: { + readonly environment: ProjectEnvironment | null; + readonly name: string; + readonly configured: boolean; + readonly path: string; + readonly enabled?: boolean; +}): boolean { + if (input.enabled === false) return input.configured; + const value = environmentOverride(input.environment, input.name, undefined); + if (value === undefined) return input.configured; + const parsed = GO_BOOLEAN_VALUES[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 environment: ProjectEnvironment | null; + readonly name: string; + readonly configured: number | undefined; + readonly path: string; + readonly max?: number; + readonly enabled?: boolean; +}): number | undefined { + if (input.enabled === false) return input.configured; + const value = environmentOverride(input.environment, input.name, undefined); + 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 environment: ProjectEnvironment | null; + readonly name: string; + readonly configured: string | undefined; + readonly enabled?: boolean; +}): string | undefined { + return input.enabled === false + ? input.configured + : environmentOverride(input.environment, input.name, input.configured); +} + +function envList(input: { + readonly environment: ProjectEnvironment | null; + readonly name: string; + readonly configured: ReadonlyArray; +}): ReadonlyArray { + const value = environmentOverride(input.environment, input.name, undefined); + return value === undefined ? input.configured : value.length === 0 ? [] : value.split(","); +} + +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 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({ + environment: input.environment, + name: `SUPABASE_AUTH_SMS_${name.toUpperCase()}_ENABLED`, + configured, + path: `auth.sms.${name}.enabled`, + enabled: providerPresent(name), + }); + const providerString = ( + name: string, + field: string, + configured: string | undefined, + ): string | undefined => + envString({ + environment: input.environment, + name: `SUPABASE_AUTH_SMS_${name.toUpperCase()}_${field.toUpperCase()}`, + 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 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 prefix = `SUPABASE_AUTH_EXTERNAL_${name.toUpperCase()}`; + const stringField = (field: string, configured: string | undefined) => + envString({ + environment: input.environment, + name: `${prefix}_${field.toUpperCase()}`, + configured, + enabled: sectionPresent, + }); + const booleanField = (field: string, configured: boolean) => + envBoolean({ + environment: input.environment, + name: `${prefix}_${field.toUpperCase()}`, + 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 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]); + const prefix = `SUPABASE_AUTH_HOOK_${name.toUpperCase()}`; + return [ + name, + { + enabled: envBoolean({ + environment: input.environment, + name: `${prefix}_ENABLED`, + configured: hook.enabled, + path: `auth.hook.${name}.enabled`, + enabled: sectionPresent, + }), + uri: envString({ + environment: input.environment, + name: `${prefix}_URI`, + configured: hook.uri, + enabled: sectionPresent, + }), + secrets: envString({ + environment: input.environment, + name: `${prefix}_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 rawDocument?: Readonly>; + readonly projectEnvironment: ProjectEnvironment | null; + readonly configDir: string; + readonly authEnabled: boolean; +}) { + const { auth } = input.projectConfig; + const authDocument = isRecord(input.rawDocument?.auth) ? input.rawDocument.auth : undefined; + const authEnabled = input.authEnabled; + const flatString = (field: string, configured: string | undefined) => + envString({ + environment: input.projectEnvironment, + name: `SUPABASE_AUTH_${field.toUpperCase()}`, + configured, + }); + const flatBoolean = (field: string, configured: boolean) => + envBoolean({ + environment: input.projectEnvironment, + name: `SUPABASE_AUTH_${field.toUpperCase()}`, + configured, + path: `auth.${field}`, + }); + const flatNumber = (field: string, configured: number) => + envNumber({ + environment: input.projectEnvironment, + name: `SUPABASE_AUTH_${field.toUpperCase()}`, + 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 (authEnabled && 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({ + environment: input.projectEnvironment, + name: "SUPABASE_AUTH_EMAIL_SMTP_ENABLED", + configured: smtpDocument.enabled === undefined ? true : auth.email.smtp?.enabled === true, + path: "auth.email.smtp.enabled", + }); + const smtpString = (field: string, configured: string | undefined) => + envString({ + environment: input.projectEnvironment, + name: `SUPABASE_AUTH_EMAIL_SMTP_${field.toUpperCase()}`, + configured, + enabled: smtpPresent, + }); + const smtp = smtpEnabled + ? { + host: required(smtpString("host", auth.email.smtp?.host), "auth.email.smtp.host"), + port: requiredNumber( + envNumber({ + environment: input.projectEnvironment, + name: "SUPABASE_AUTH_EMAIL_SMTP_PORT", + 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({ + environment: input.projectEnvironment, + name: "SUPABASE_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({ + environment: input.projectEnvironment, + name: "SUPABASE_AUTH_EMAIL_ENABLE_SIGNUP", + configured: auth.email.enable_signup, + path: "auth.email.enable_signup", + }), + doubleConfirmChanges: envBoolean({ + environment: input.projectEnvironment, + name: "SUPABASE_AUTH_EMAIL_DOUBLE_CONFIRM_CHANGES", + configured: auth.email.double_confirm_changes, + path: "auth.email.double_confirm_changes", + }), + enableConfirmations: envBoolean({ + environment: input.projectEnvironment, + name: "SUPABASE_AUTH_EMAIL_ENABLE_CONFIRMATIONS", + configured: auth.email.enable_confirmations, + path: "auth.email.enable_confirmations", + }), + securePasswordChange: envBoolean({ + environment: input.projectEnvironment, + name: "SUPABASE_AUTH_EMAIL_SECURE_PASSWORD_CHANGE", + configured: auth.email.secure_password_change, + path: "auth.email.secure_password_change", + }), + maxFrequency: + envString({ + environment: input.projectEnvironment, + name: "SUPABASE_AUTH_EMAIL_MAX_FREQUENCY", + configured: auth.email.max_frequency, + }) ?? auth.email.max_frequency, + otpLength: + envNumber({ + environment: input.projectEnvironment, + name: "SUPABASE_AUTH_EMAIL_OTP_LENGTH", + configured: auth.email.otp_length, + path: "auth.email.otp_length", + }) ?? auth.email.otp_length, + otpExpiry: + envNumber({ + environment: input.projectEnvironment, + name: "SUPABASE_AUTH_EMAIL_OTP_EXPIRY", + configured: auth.email.otp_expiry, + path: "auth.email.otp_expiry", + }) ?? auth.email.otp_expiry, + smtp, + }, + sms: { + enableSignup: envBoolean({ + environment: input.projectEnvironment, + name: "SUPABASE_AUTH_SMS_ENABLE_SIGNUP", + configured: auth.sms.enable_signup, + path: "auth.sms.enable_signup", + }), + enableConfirmations: envBoolean({ + environment: input.projectEnvironment, + name: "SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS", + configured: auth.sms.enable_confirmations, + path: "auth.sms.enable_confirmations", + }), + template: + envString({ + environment: input.projectEnvironment, + name: "SUPABASE_AUTH_SMS_TEMPLATE", + configured: auth.sms.template, + }) ?? auth.sms.template, + maxFrequency: + envString({ + environment: input.projectEnvironment, + name: "SUPABASE_AUTH_SMS_MAX_FREQUENCY", + configured: auth.sms.max_frequency, + }) ?? auth.sms.max_frequency, + testOtp: auth.sms.test_otp, + provider: resolveSmsProvider({ + sms: auth.sms, + authDocument, + environment: input.projectEnvironment, + }), + }, + externalProviders: resolveExternalProviders({ + external: auth.external, + authDocument, + environment: input.projectEnvironment, + }), + hooks: resolveHooks({ + hooks: auth.hook, + authDocument, + 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..1c395b83d7 --- /dev/null +++ b/apps/cli/src/next/config/auth-stack-config.unit.test.ts @@ -0,0 +1,281 @@ +import { 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: {}, + }; +} + +describe("translateAuthStackConfig", () => { + it("translates signup, email, SMS, providers, redirects, hooks, and credentials", async () => { + const result = await Effect.runPromise( + translateAuthStackConfig({ + 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( + translateAuthStackConfig({ + 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( + translateAuthStackConfig({ + 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( + translateAuthStackConfig({ + 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("reports malformed Auth overrides by path without their values", async () => { + const exit = await Effect.runPromise( + translateAuthStackConfig({ + 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( + translateAuthStackConfig({ + 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("does not read signing keys when Auth is excluded", async () => { + const result = await Effect.runPromise( + translateAuthStackConfig({ + configDir: "/missing", + authEnabled: false, + projectEnvironment: null, + projectConfig: decodeProjectConfig({ + auth: { signing_keys_path: "missing.json" }, + }), + }), + ); + + expect(result.auth).toBe(false); + expect(result.credentials.signing?._tag).toBe("SymmetricJwtSecret"); + }); +}); diff --git a/apps/cli/src/next/config/local-stack-config-parity.ts b/apps/cli/src/next/config/local-stack-config-parity.ts index 8dee4cb21b..528bf77932 100644 --- a/apps/cli/src/next/config/local-stack-config-parity.ts +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -83,6 +83,26 @@ const mappedCoreTopologyField: LocalStackConfigParityDecision = { "The launch Adapter applies project values, legacy environment overrides, and CLI exclusions before constructing StackConfig.", }; +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 projectIdentityField: LocalStackConfigParityDecision = { _tag: "not-applicable", presence: "raw-document", @@ -137,19 +157,19 @@ const unsupportedFutureRuntimeField: LocalStackConfigParityDecision = { }; const authExternalProviderParity = { - enabled: unsupportedRuntimeField, - client_id: unsupportedRuntimeField, - secret: unsupportedSecretRuntimeField, - url: unsupportedRuntimeField, - redirect_uri: unsupportedRuntimeField, - skip_nonce_check: unsupportedRuntimeField, - email_optional: unsupportedRuntimeField, + enabled: mappedAuthRuntimeField, + client_id: mappedAuthRuntimeField, + secret: mappedAuthSecretRuntimeField, + url: mappedAuthRuntimeField, + redirect_uri: mappedAuthRuntimeField, + skip_nonce_check: mappedAuthRuntimeField, + email_optional: mappedAuthRuntimeField, } satisfies Record; const authHookParity = { - enabled: unsupportedRuntimeField, - uri: unsupportedOptionalRuntimeField, - secrets: unsupportedSecretRuntimeField, + enabled: mappedAuthRuntimeField, + uri: mappedAuthOptionalRuntimeField, + secrets: mappedAuthSecretRuntimeField, } satisfies Record; const authRateLimitParity = { @@ -194,60 +214,60 @@ const authHooksParity = { } satisfies Record; const authSmsParity = { - enable_signup: unsupportedRuntimeField, - enable_confirmations: unsupportedRuntimeField, - template: unsupportedRuntimeField, - max_frequency: unsupportedRuntimeField, + enable_signup: mappedAuthRuntimeField, + enable_confirmations: mappedAuthRuntimeField, + template: mappedAuthRuntimeField, + max_frequency: mappedAuthRuntimeField, twilio: { - enabled: unsupportedRuntimeField, - account_sid: unsupportedRuntimeField, - message_service_sid: unsupportedRuntimeField, - auth_token: unsupportedSecretRuntimeField, + enabled: mappedAuthRuntimeField, + account_sid: mappedAuthRuntimeField, + message_service_sid: mappedAuthRuntimeField, + auth_token: mappedAuthSecretRuntimeField, } satisfies Record, twilio_verify: { - enabled: unsupportedRuntimeField, - account_sid: unsupportedOptionalRuntimeField, - message_service_sid: unsupportedOptionalRuntimeField, - auth_token: unsupportedSecretRuntimeField, + enabled: mappedAuthRuntimeField, + account_sid: mappedAuthOptionalRuntimeField, + message_service_sid: mappedAuthOptionalRuntimeField, + auth_token: mappedAuthSecretRuntimeField, } satisfies Record, messagebird: { - enabled: unsupportedRuntimeField, - originator: unsupportedOptionalRuntimeField, - access_key: unsupportedSecretRuntimeField, + enabled: mappedAuthRuntimeField, + originator: mappedAuthOptionalRuntimeField, + access_key: mappedAuthSecretRuntimeField, } satisfies Record, textlocal: { - enabled: unsupportedRuntimeField, - sender: unsupportedOptionalRuntimeField, - api_key: unsupportedSecretRuntimeField, + enabled: mappedAuthRuntimeField, + sender: mappedAuthOptionalRuntimeField, + api_key: mappedAuthSecretRuntimeField, } satisfies Record, vonage: { - enabled: unsupportedRuntimeField, - from: unsupportedOptionalRuntimeField, - api_key: unsupportedOptionalRuntimeField, - api_secret: unsupportedSecretRuntimeField, + enabled: mappedAuthRuntimeField, + from: mappedAuthOptionalRuntimeField, + api_key: mappedAuthOptionalRuntimeField, + api_secret: mappedAuthSecretRuntimeField, } satisfies Record, - test_otp: unsupportedOptionalRuntimeField, + test_otp: mappedAuthOptionalRuntimeField, } satisfies Record; const authParity = { - enabled: mappedCoreTopologyField, - site_url: unsupportedRuntimeField, - additional_redirect_urls: unsupportedRuntimeField, - jwt_expiry: unsupportedRuntimeField, - jwt_issuer: unsupportedOptionalRuntimeField, - signing_keys_path: unsupportedOptionalRuntimeField, - enable_refresh_token_rotation: unsupportedRuntimeField, - refresh_token_reuse_interval: unsupportedRuntimeField, - enable_manual_linking: unsupportedRuntimeField, - enable_signup: unsupportedRuntimeField, - enable_anonymous_sign_ins: unsupportedRuntimeField, - minimum_password_length: unsupportedRuntimeField, - password_requirements: unsupportedRuntimeField, - publishable_key: unsupportedSecretRuntimeField, - secret_key: unsupportedSecretRuntimeField, - jwt_secret: unsupportedSecretRuntimeField, - anon_key: unsupportedSecretRuntimeField, - service_role_key: unsupportedSecretRuntimeField, + enabled: mappedAuthRuntimeField, + 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, @@ -278,21 +298,21 @@ const authParity = { inactivity_timeout: unsupportedOptionalRuntimeField, } satisfies Record, Node>, email: { - enable_signup: unsupportedRuntimeField, - double_confirm_changes: unsupportedRuntimeField, - enable_confirmations: unsupportedRuntimeField, - secure_password_change: unsupportedRuntimeField, - max_frequency: unsupportedRuntimeField, - otp_length: unsupportedRuntimeField, - otp_expiry: unsupportedRuntimeField, + 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: unsupportedRuntimeField, - host: unsupportedOptionalRuntimeField, - port: unsupportedOptionalRuntimeField, - user: unsupportedOptionalRuntimeField, - pass: unsupportedSecretRuntimeField, - admin_email: unsupportedOptionalRuntimeField, - sender_name: unsupportedOptionalRuntimeField, + enabled: mappedAuthRuntimeField, + host: mappedAuthOptionalRuntimeField, + port: mappedAuthOptionalRuntimeField, + user: mappedAuthOptionalRuntimeField, + pass: mappedAuthSecretRuntimeField, + admin_email: mappedAuthOptionalRuntimeField, + sender_name: mappedAuthOptionalRuntimeField, } satisfies Record, Node>, template: { "*": { 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 index 597b35cd90..a6f5029cd8 100644 --- 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 @@ -17,20 +17,19 @@ describe("localStackConfigParity", () => { ]), ), ).toEqual({ - mapped: 42, + mapped: 247, "not-applicable": 10, - "unsupported-blocking": 303, + "unsupported-blocking": 98, "unsupported-warning": 6, }); }); - it("claims only behavior implemented by a current next local-runtime flow as mapped", () => { - expect( - entries - .filter(({ decision }) => decision._tag === "mapped") - .map(({ path }) => path) - .sort(), - ).toEqual([ + 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.port", @@ -40,7 +39,6 @@ describe("localStackConfigParity", () => { "api.max_rows", "api.port", "api.schemas", - "auth.enabled", "db.health_timeout", "db.pooler.default_pool_size", "db.pooler.enabled", @@ -74,6 +72,24 @@ describe("localStackConfigParity", () => { "studio.enabled", "studio.port", ]); + expect(mappedPaths.filter((path) => path.startsWith("auth."))).toHaveLength(206); + 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 raw-document requirements for presence-sensitive sections", () => { diff --git a/apps/cli/src/next/config/stack-config.integration.test.ts b/apps/cli/src/next/config/stack-config.integration.test.ts index c86abdb37e..093c76ba5b 100644 --- a/apps/cli/src/next/config/stack-config.integration.test.ts +++ b/apps/cli/src/next/config/stack-config.integration.test.ts @@ -65,4 +65,95 @@ describe("local stack launch config", () => { 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( + resolveLocalStackLaunch({ + 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([]); + } 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 a2d468153e..40ad833317 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -6,7 +6,9 @@ import { } from "@supabase/config"; import type { ReadinessPolicy, 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 { excludedStackServices, invalidLocalStackConfig, @@ -336,12 +338,30 @@ export const resolveLocalStackLaunch = Effect.fnUntraced(function* (input: Local paths: [], }), }); + const translatedAuth = yield* translateAuthStackConfig({ + projectConfig, + rawDocument: input.loadedProjectConfig?.document, + projectEnvironment: input.projectEnvironment, + configDir: + input.loadedProjectConfig === null + ? join(input.projectPaths.projectRoot, "supabase") + : dirname(input.loadedProjectConfig.path), + authEnabled: coreConfig.auth !== false, + }); return { stackConfig: { ...coreConfig, projectDir: input.projectPaths.projectRoot, readiness, + credentials: translatedAuth.credentials, + auth: + translatedAuth.auth === false + ? false + : { + ...translatedAuth.auth, + version: versionedConfig.auth === false ? undefined : versionedConfig.auth?.version, + }, postgres: { ...coreConfig.postgres, autoExposeNewTables, 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 d969f6c1aa..19a50288f7 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -291,15 +291,15 @@ describe("resolveLocalStackLaunch", () => { resolveLocalStackLaunch({ ...baseLaunchInput, loadedProjectConfig: loaded({ - auth: { jwt_secret: "do-not-leak" }, - realtime: { ip_version: "IPv6" }, + auth: { captcha: { secret: "do-not-leak" } }, + api: { tls: { cert_path: "another-private-value" } }, }), }).pipe(Effect.exit), ); expect(exit._tag).toBe("Failure"); - expect(JSON.stringify(exit)).toContain("auth.jwt_secret"); - expect(JSON.stringify(exit)).toContain("realtime.ip_version"); + expect(JSON.stringify(exit)).toContain("auth.captcha.secret"); + expect(JSON.stringify(exit)).toContain("api.tls.cert_path"); expect(JSON.stringify(exit)).not.toContain("do-not-leak"); expect(JSON.stringify(exit)).not.toContain("another-private-value"); }); diff --git a/packages/stack/README.md b/packages/stack/README.md index 17299760b4..90dd2425f3 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(); @@ -81,7 +91,8 @@ 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. | | `readiness` | finite or infinite policy | No | `120s` | Stack-wide readiness deadline. Per-call readiness options take precedence. | -| `jwtSecret` | `string` | No | | Secret for JWT signing (min 32 characters). Defaults to a well-known dev secret | +| `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 | @@ -113,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 52d0de630c..fd4cf04e80 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -37,14 +37,15 @@ 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, +top-level fields choose runtime mode, startup mode, cache/runtime roots, local credentials, functions options, 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. @@ -124,6 +125,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/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/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/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index b4a5b9a71f..e67c8ad863 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -5,6 +5,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 type { AllocatedPorts, PortField, PortLease } from "./PortAllocator.ts"; import { StackServiceActivator } from "./ServiceActivation.ts"; import { Stack } from "./Stack.ts"; @@ -16,6 +17,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, @@ -46,6 +50,7 @@ const defaultConfig: ResolvedStackConfig = { mode: "native", startupMode: "eager", readiness: DEFAULT_STACK_READINESS_POLICY, + credentials: testCredentials, jwtSecret: testJwtSecret, ports: defaultPorts, apiPort: 54321, @@ -73,8 +78,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, diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index 16fe9d3a53..ac63d44386 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -3,7 +3,6 @@ import type { ResolvedGraph, ServiceDef } from "@supabase/process-compose"; import { Effect, Layer, Context } from "effect"; import type { CleanupTargets } from "./CleanupTargets.ts"; import { StackBuildError } from "./errors.ts"; -import { generateJwks } from "./JwtGenerator.ts"; import { detectPlatform, dockerHostAddress, @@ -237,7 +236,7 @@ export class StackBuilder extends Context.Service< ); const hasPostgresInit = postgresResolution.type === "binary"; const postgresDeps = dependsOnPostgres(hasPostgresInit); - const jwtJwks = generateJwks(config.jwtSecret); + const jwtJwks = config.credentials.jwks; const defs: Array = [ { @@ -320,14 +319,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.smtpTransportPort : 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({ @@ -335,14 +338,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.smtpTransportPort : 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, + }, networkArgs: dockerNetworkArgs(platform.os, [config.auth.port]), apiPort: config.apiPort, dependencies: postgresDeps, diff --git a/packages/stack/src/StackBuilder.unit.test.ts b/packages/stack/src/StackBuilder.unit.test.ts index 9f9ce01588..e40ec8a693 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 { StackBuilder } from "./StackBuilder.ts"; import type { BuildResult } from "./StackBuilder.ts"; import { DEFAULT_STACK_READINESS_POLICY, type ResolvedStackConfig } from "./StackConfig.ts"; @@ -14,6 +15,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, @@ -44,6 +48,7 @@ const baseConfig: ResolvedStackConfig = { mode: "auto", startupMode: "eager", readiness: DEFAULT_STACK_READINESS_POLICY, + credentials: testCredentials, jwtSecret: testJwtSecret, ports: basePorts, apiPort: 3000, @@ -71,8 +76,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, diff --git a/packages/stack/src/StackConfig.ts b/packages/stack/src/StackConfig.ts index 6b773b43a3..3c269b884f 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 { FunctionsConfig, ResolvedFunctionsConfig } from "./functions.ts"; +import type { LocalCredentials, ResolvedLocalCredentials } from "./LocalCredentials.ts"; import type { AllocatedPorts } from "./PortAllocator.ts"; type StackMode = "native" | "auto" | "docker"; @@ -70,13 +72,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; @@ -164,6 +160,8 @@ 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; @@ -201,13 +199,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; @@ -294,6 +286,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; diff --git a/packages/stack/src/StackConfigResolver.ts b/packages/stack/src/StackConfigResolver.ts index d3fca75a41..377ebf3c03 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, }; } @@ -498,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, @@ -510,16 +539,17 @@ 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, postgres: { port: ports.dbPort, dataDir: postgresDataDir, diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index e757aaeb58..bf80151b47 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -10,6 +10,7 @@ export { ChecksumMismatchError, DockerPullError, DownloadError, + LocalCredentialsError, PortConflictError, StackBuildError, StackError, @@ -40,6 +41,26 @@ export { generateJwt, JwtGenerator, } 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, 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/services/auth.ts b/packages/stack/src/services/auth.ts index 355b8265c6..316f2bd384 100644 --- a/packages/stack/src/services/auth.ts +++ b/packages/stack/src/services/auth.ts @@ -1,18 +1,17 @@ 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 { dockerServiceCleanup, dockerServiceOrphanCleanup } from "./docker-cleanup.ts"; import { stackHealthBudgets } from "./health-budgets.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<{ readonly service: string; readonly condition: "healthy" | "completed"; @@ -30,42 +29,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; + } +} + +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) => ({ +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, }); @@ -73,7 +194,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: {}, @@ -81,7 +209,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, + }); const envArgs = Object.entries(env).flatMap(([k, v]) => ["-e", `${k}=${v}`]); const containerName = `supabase-auth-${opts.apiPort}`; diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index d495155178..689825811d 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -27,6 +27,39 @@ const POSTGREST_BIN_PATH = `/cache/postgrest/${DEFAULT_VERSIONS.postgrest}/macos 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 LINUX_HOST_GATEWAY_ARGS = ["--add-host", "host.docker.internal:host-gateway"]; +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("makePostgresService", () => { it("creates a postgres ServiceDef with correct defaults", () => { @@ -338,10 +371,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" }], }); @@ -360,6 +392,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", () => { @@ -368,10 +491,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", networkArgs: [...LINUX_HOST_GATEWAY_ARGS, "-p", "9999:9999"], apiPort: API_PORT, From 3e70eb6033a98e37a286eb2e7d4847c7ff75db2a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 17:00:36 +0200 Subject: [PATCH 06/26] test(cli): refine config parity decisions --- .../next/config/local-stack-config-parity.ts | 76 ++++++++++++++----- .../local-stack-config-parity.unit.test.ts | 30 +++++++- 2 files changed, 83 insertions(+), 23 deletions(-) diff --git a/apps/cli/src/next/config/local-stack-config-parity.ts b/apps/cli/src/next/config/local-stack-config-parity.ts index 57a2abdf6a..7d03314a26 100644 --- a/apps/cli/src/next/config/local-stack-config-parity.ts +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -33,10 +33,18 @@ type LocalStackConfigParityDecision = }; export interface LocalStackConfigParitySection { - readonly [field: string]: LocalStackConfigParityDecision | LocalStackConfigParitySection; + readonly [field: string]: Node; } -type Node = LocalStackConfigParityDecision | LocalStackConfigParitySection; +interface LocalStackConfigParityBranch { + readonly decision: LocalStackConfigParityDecision; + readonly children: LocalStackConfigParitySection; +} + +type Node = + | LocalStackConfigParityDecision + | LocalStackConfigParityBranch + | LocalStackConfigParitySection; const unsupportedRuntimeField: LocalStackConfigParityDecision = { _tag: "unsupported-blocking", @@ -99,6 +107,13 @@ const commandOnlyDatabaseField: LocalStackConfigParityDecision = { "This field configures database tooling outside local stack startup and does not belong in StackConfig.", }; +const projectMetadataField: LocalStackConfigParityDecision = { + _tag: "not-applicable", + presence: "raw-document", + rationale: + "The project identifier distinguishes local project directories and is not local stack runtime configuration.", +}; + const remoteOverlayField: LocalStackConfigParityDecision = { _tag: "not-applicable", presence: "raw-document", @@ -273,16 +288,22 @@ const authParity = { } satisfies Record, Node>, template: { "*": { - subject: unsupportedRuntimeField, - content_path: unsupportedRuntimeField, - } satisfies Record, + decision: unsupportedRuntimeField, + children: { + subject: unsupportedRuntimeField, + content_path: unsupportedRuntimeField, + } satisfies Record, + }, }, notification: { "*": { - enabled: unsupportedRuntimeField, - subject: unsupportedRuntimeField, - content_path: unsupportedRuntimeField, - } satisfies Record, + decision: unsupportedRuntimeField, + children: { + enabled: unsupportedRuntimeField, + subject: unsupportedRuntimeField, + content_path: unsupportedRuntimeField, + } satisfies Record, + }, }, } satisfies Record, sms: authSmsParity, @@ -363,7 +384,7 @@ const dbSettingsParity = { * classified at the record field itself. */ const localStackConfigParity = { - project_id: unsupportedOptionalRuntimeField, + project_id: projectMetadataField, analytics: { enabled: unsupportedRuntimeField, port: unsupportedRuntimeField, @@ -427,7 +448,10 @@ const localStackConfigParity = { secrets: mappedFunctionsDevEdgeRuntime, } satisfies Record, functions: { - "*": functionConfigParity, + "*": { + decision: mappedFunctionManifest, + children: functionConfigParity, + }, }, local_smtp: { enabled: unsupportedRuntimeField, @@ -450,11 +474,14 @@ const localStackConfigParity = { } satisfies Record, Node>, buckets: { "*": { - public: unsupportedRuntimeField, - file_size_limit: unsupportedRuntimeField, - allowed_mime_types: unsupportedRuntimeField, - objects_path: unsupportedRuntimeField, - } satisfies Record[string], Node>, + decision: unsupportedRuntimeField, + children: { + public: unsupportedRuntimeField, + file_size_limit: unsupportedRuntimeField, + allowed_mime_types: unsupportedRuntimeField, + objects_path: unsupportedRuntimeField, + } satisfies Record[string], Node>, + }, }, s3_protocol: { enabled: unsupportedRuntimeField, @@ -509,6 +536,10 @@ 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, @@ -516,8 +547,15 @@ export function flattenLocalStackConfigParity( ): ReadonlyArray { return Object.entries(section).flatMap(([field, node]) => { const path = prefix === "" ? field : `${prefix}.${field}`; - return isDecision(node) - ? [{ path, decision: node }] - : flattenLocalStackConfigParity(node, path); + if (isDecision(node)) { + return [{ path, decision: node }]; + } + if (isBranch(node)) { + return [ + { path, decision: node.decision }, + ...flattenLocalStackConfigParity(node.children, path), + ]; + } + return flattenLocalStackConfigParity(node, path); }); } 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 index 69a75e96a4..9cc94bb536 100644 --- 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 @@ -7,7 +7,7 @@ describe("localStackConfigParity", () => { it("classifies every fixed project-config leaf exactly once", () => { const paths = entries.map(({ path }) => path); - expect(paths).toHaveLength(361); + expect(paths).toHaveLength(365); expect(new Set(paths).size).toBe(paths.length); expect( Object.fromEntries( @@ -17,9 +17,9 @@ describe("localStackConfigParity", () => { ]), ), ).toEqual({ - mapped: 11, - "not-applicable": 9, - "unsupported-blocking": 335, + mapped: 12, + "not-applicable": 10, + "unsupported-blocking": 337, "unsupported-warning": 6, }); }); @@ -36,6 +36,7 @@ describe("localStackConfigParity", () => { "edge_runtime.inspector_port", "edge_runtime.policy", "edge_runtime.secrets", + "functions.*", "functions.*.enabled", "functions.*.entrypoint", "functions.*.env", @@ -43,6 +44,25 @@ describe("localStackConfigParity", () => { "functions.*.static_files", "functions.*.verify_jwt", ]); + + expect( + entries.flatMap(({ path, decision }) => + decision._tag === "mapped" ? [`${decision.mappedBy}:${path}`] : [], + ), + ).toEqual([ + "start:api.auto_expose_new_tables", + "functions-dev:edge_runtime.enabled", + "functions-dev:edge_runtime.policy", + "functions-dev:edge_runtime.inspector_port", + "functions-dev:edge_runtime.secrets", + "stack-functions-runtime:functions.*", + "stack-functions-runtime:functions.*.enabled", + "stack-functions-runtime:functions.*.verify_jwt", + "stack-functions-runtime:functions.*.import_map", + "stack-functions-runtime:functions.*.entrypoint", + "stack-functions-runtime:functions.*.static_files", + "stack-functions-runtime:functions.*.env", + ]); }); it("preserves raw-document requirements for presence-sensitive sections", () => { @@ -53,6 +73,7 @@ describe("localStackConfigParity", () => { expect(byPath.get("auth.hook.send_email.enabled")?.presence).toBe("raw-document"); expect(byPath.get("auth.sms.twilio.enabled")?.presence).toBe("raw-document"); expect(byPath.get("storage.image_transformation.enabled")?.presence).toBe("raw-document"); + expect(byPath.get("storage.buckets.*")?.presence).toBe("raw-document"); expect(byPath.get("experimental.webhooks.enabled")?.presence).toBe("raw-document"); }); @@ -71,6 +92,7 @@ describe("localStackConfigParity", () => { "experimental.pgdelta.declarative_schema_path", "experimental.pgdelta.enabled", "experimental.pgdelta.format_options", + "project_id", "remotes", ]); }); From 678f9203c0ae722e2014674042baf32087e53b5a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 17:18:35 +0200 Subject: [PATCH 07/26] feat(stack): add database bootstrap phases --- .../commands/start/start.integration.test.ts | 52 ++- .../next/config/database-bootstrap-config.ts | 309 ++++++++++++++++++ .../database-bootstrap-config.unit.test.ts | 205 ++++++++++++ .../next/config/local-stack-config-parity.ts | 14 +- .../local-stack-config-parity.unit.test.ts | 7 +- .../config/stack-config.integration.test.ts | 68 +++- apps/cli/src/next/config/stack-config.ts | 34 +- .../src/next/config/stack-config.unit.test.ts | 10 +- packages/stack/src/Stack.unit.test.ts | 1 + packages/stack/src/StackBuilder.ts | 71 +++- packages/stack/src/StackBuilder.unit.test.ts | 87 +++++ packages/stack/src/StackConfig.ts | 23 ++ packages/stack/src/StackConfigResolver.ts | 4 + packages/stack/src/effect.ts | 3 + packages/stack/src/index.ts | 2 + .../stack/src/services/database-bootstrap.ts | 224 +++++++++++++ packages/stack/src/services/postgrest.ts | 8 +- .../stack/src/services/services.unit.test.ts | 122 ++++++- 18 files changed, 1205 insertions(+), 39 deletions(-) create mode 100644 apps/cli/src/next/config/database-bootstrap-config.ts create mode 100644 apps/cli/src/next/config/database-bootstrap-config.unit.test.ts create mode 100644 packages/stack/src/services/database-bootstrap.ts 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..5cbf728335 100644 --- a/apps/cli/src/next/commands/start/start.integration.test.ts +++ b/apps/cli/src/next/commands/start/start.integration.test.ts @@ -6,7 +6,8 @@ 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 { 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 { startForegroundWithStopSignal } from "./flows/foreground.flow.ts"; @@ -641,4 +642,53 @@ 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", "migrations"), { recursive: true }); + const migration = join(projectRoot, "supabase", "migrations", "20260805000000_start.sql"); + const seed = join(projectRoot, "supabase", "seed.sql"); + await writeFile(migration, "create table start_bootstrap(id bigint);"); + await writeFile(seed, "insert into start_bootstrap values (1);"); + await writeFile( + join(projectRoot, "supabase", "config.toml"), + [ + "[db.migrations]", + "enabled = true", + "", + "[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?.migrationFiles).toEqual([migration]); + 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/config/database-bootstrap-config.ts b/apps/cli/src/next/config/database-bootstrap-config.ts new file mode 100644 index 0000000000..9eb0a3153b --- /dev/null +++ b/apps/cli/src/next/config/database-bootstrap-config.ts @@ -0,0 +1,309 @@ +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]{14})_(.+)\.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(","); +} + +function rawDefines(loaded: LoadedProjectConfig, path: ReadonlyArray): boolean { + return nestedValue(loaded.document, path) !== undefined; +} + +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 schemaPathsConfigured = + rawDefines(loaded, ["db", "migrations", "schema_paths"]) || + remoteDefines(loaded, ["db", "migrations", "schema_paths"]) || + environmentOverride("SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS", input.projectEnvironment) !== + undefined; + if (schemaPathsConfigured) { + 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, + }); + + const migrationFiles = migrationsEnabled ? await conventionalMigrationFiles(configDir) : []; + 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 = + migrationFiles.length === 0 && seedFiles.length === 0 + ? undefined + : { migrationFiles, 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..5f8e74e19b --- /dev/null +++ b/apps/cli/src/next/config/database-bootstrap-config.unit.test.ts @@ -0,0 +1,205 @@ +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 conventional migrations and ordered, deduplicated seed inputs", async () => { + const projectRoot = await mkdtemp(join(tmpdir(), "stack-database-bootstrap-")); + const supabaseDir = join(projectRoot, "supabase"); + try { + await mkdir(join(supabaseDir, "migrations"), { recursive: true }); + await mkdir(join(supabaseDir, "seeds", "nested"), { recursive: true }); + await writeFile(join(supabaseDir, "migrations", "20240202000000_second.sql"), "select 2;"); + await writeFile(join(supabaseDir, "migrations", "20240101000000_first.sql"), "select 1;"); + await writeFile(join(supabaseDir, "migrations", "notes.sql"), "select 0;"); + 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: true }, + seed: { enabled: true, sql_paths: ["./seeds", "./seeds/a.sql"] }, + }, + }), + projectEnvironment: null, + projectRoot, + }), + ); + + expect(result.config?.migrationFiles).toEqual([ + join(supabaseDir, "migrations", "20240101000000_first.sql"), + join(supabaseDir, "migrations", "20240202000000_second.sql"), + ]); + 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("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: true }, + seed: { enabled: false }, + }, + remotes: { + staging: { + project_id: "abcdefghijklmnopqrst", + db: { migrations: { enabled: true }, 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?.migrationFiles).toEqual([ + join(supabaseDir, "migrations", "20240101000000_remote.sql"), + ]); + expect(result.config?.seedFiles).toEqual([]); + } 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/local-stack-config-parity.ts b/apps/cli/src/next/config/local-stack-config-parity.ts index 528bf77932..4e7a70c211 100644 --- a/apps/cli/src/next/config/local-stack-config-parity.ts +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -75,6 +75,14 @@ const mappedDatabaseHealthTimeout: LocalStackConfigParityDecision = { "The launch Adapter resolves the legacy environment override, applies the duration to PostgreSQL startup health, and derives the stack readiness deadline from it.", }; +const mappedDatabaseBootstrapField: LocalStackConfigParityDecision = { + _tag: "mapped", + presence: "raw-document", + mappedBy: "start", + rationale: + "The launch Adapter expands ordered SQL inputs and the stack executes them as internal PostgreSQL bootstrap phases.", +}; + const mappedCoreTopologyField: LocalStackConfigParityDecision = { _tag: "mapped", presence: "raw-document", @@ -444,12 +452,12 @@ const localStackConfigParity = { max_client_conn: mappedCoreTopologyField, } satisfies Record, migrations: { - enabled: unsupportedRuntimeField, + enabled: mappedDatabaseBootstrapField, schema_paths: unsupportedRuntimeField, } satisfies Record, seed: { - enabled: unsupportedRuntimeField, - sql_paths: unsupportedRuntimeField, + enabled: mappedDatabaseBootstrapField, + sql_paths: mappedDatabaseBootstrapField, } satisfies Record, settings: dbSettingsParity, network_restrictions: { 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 index a6f5029cd8..f64774d762 100644 --- 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 @@ -17,9 +17,9 @@ describe("localStackConfigParity", () => { ]), ), ).toEqual({ - mapped: 247, + mapped: 250, "not-applicable": 10, - "unsupported-blocking": 98, + "unsupported-blocking": 95, "unsupported-warning": 6, }); }); @@ -40,12 +40,15 @@ describe("localStackConfigParity", () => { "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.inspector_port", "edge_runtime.policy", diff --git a/apps/cli/src/next/config/stack-config.integration.test.ts b/apps/cli/src/next/config/stack-config.integration.test.ts index 093c76ba5b..ad450712be 100644 --- a/apps/cli/src/next/config/stack-config.integration.test.ts +++ b/apps/cli/src/next/config/stack-config.integration.test.ts @@ -60,6 +60,10 @@ describe("local stack launch config", () => { 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 }); @@ -151,7 +155,69 @@ describe("local stack launch config", () => { custom_access_token: { enabled: true, secrets: "hook-secret" }, }, }); - expect(result.warnings).toEqual([]); + 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 database bootstrap 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, "migrations"), { recursive: true }); + await mkdir(join(supabaseDir, "seeds"), { recursive: true }); + const migration = join(supabaseDir, "migrations", "20260805000000_create_widgets.sql"); + const seedSecond = join(supabaseDir, "seeds", "02_widgets.sql"); + const seedFirst = join(supabaseDir, "seeds", "01_accounts.sql"); + await writeFile(migration, "create table widgets(id bigint primary key);"); + await writeFile(seedFirst, "insert into widgets values (1);"); + await writeFile(seedSecond, "insert into widgets values (2);"); + await writeFile( + join(supabaseDir, "config.toml"), + [ + "[db.migrations]", + "enabled = true", + "", + "[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( + resolveLocalStackLaunch({ + loadedProjectConfig, + projectEnvironment, + projectPaths: { projectRoot, projectStateRoot: join(projectRoot, ".supabase") }, + mode: "auto", + exclude: [], + runtimeVersions: {}, + }), + ); + + expect(result.stackConfig.databaseBootstrap).toMatchObject({ + migrationFiles: [migration], + }); + 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 }); } diff --git a/apps/cli/src/next/config/stack-config.ts b/apps/cli/src/next/config/stack-config.ts index 40ad833317..6f181c36b9 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -16,6 +16,7 @@ import { resolveCoreStackConfig, type ExcludedStackService, } from "./core-stack-config.ts"; +import { translateDatabaseBootstrapConfig } from "./database-bootstrap-config.ts"; import { flattenLocalStackConfigParity, type LocalStackConfigParityDecision, @@ -48,7 +49,7 @@ export interface LocalStackLaunchInput { } export interface LocalStackWarning { - readonly code: "unsupported" | "deprecated"; + readonly code: "unsupported" | "deprecated" | "unmatched-seed-pattern"; readonly paths: ReadonlyArray; readonly message: string; } @@ -348,6 +349,24 @@ export const resolveLocalStackLaunch = Effect.fnUntraced(function* (input: Local : dirname(input.loadedProjectConfig.path), authEnabled: coreConfig.auth !== false, }); + 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, + }, + ]; return { stackConfig: { @@ -355,6 +374,7 @@ export const resolveLocalStackLaunch = Effect.fnUntraced(function* (input: Local projectDir: input.projectPaths.projectRoot, readiness, credentials: translatedAuth.credentials, + databaseBootstrap: translatedDatabaseBootstrap.config, auth: translatedAuth.auth === false ? false @@ -369,17 +389,7 @@ export const resolveLocalStackLaunch = Effect.fnUntraced(function* (input: Local }, }, projectPaths: input.projectPaths, - warnings: - deprecationWarning === undefined - ? diagnostics.warnings - : [ - ...diagnostics.warnings, - { - code: "deprecated", - paths: ["api.auto_expose_new_tables"], - message: deprecationWarning, - }, - ], + warnings: [...diagnostics.warnings, ...databaseWarnings, ...deprecationWarnings], } satisfies ResolvedLocalStackLaunch; }); 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 19a50288f7..3a61f9c1bb 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -240,7 +240,11 @@ describe("resolveLocalStackLaunch", () => { 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", "deprecated"]); + expect(result.warnings.map(({ code }) => code)).toEqual([ + "unsupported", + "unmatched-seed-pattern", + "deprecated", + ]); }); it("uses the resolved project environment for the database health timeout", async () => { @@ -316,6 +320,10 @@ describe("resolveLocalStackLaunch", () => { 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"); }); diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index e67c8ad863..3d26acd717 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -61,6 +61,7 @@ const defaultConfig: ResolvedStackConfig = { autoManagedPaths: [], anonJwt: generateJwt(testJwtSecret, "anon"), serviceRoleJwt: generateJwt(testJwtSecret, "service_role"), + databaseBootstrap: { migrationFiles: [], seedFiles: [] }, postgres: { port: 54322, dataDir: "/tmp/supabase/data", diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index ac63d44386..7a3e42adee 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -11,6 +11,11 @@ import { } from "./Platform.ts"; import { analyticsDockerRuntimeNetwork, makeAnalyticsServiceDocker } from "./services/analytics.ts"; import { makeAuthServiceDocker, makeAuthServiceNative } from "./services/auth.ts"; +import { + makeDatabaseMigrationService, + makeDatabaseSeedService, + type DatabaseBootstrapRuntime, +} from "./services/database-bootstrap.ts"; import { makeEdgeRuntimeServiceDocker, makeEdgeRuntimeServiceNative, @@ -55,7 +60,6 @@ const dependsOnPostgres = (hasPostgresInit: boolean): ReadonlyArray, - hasPostgresInit: boolean, ): StackServiceProjectionCatalog => { const serviceProjection: Map< string, @@ -66,12 +70,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-migrations", "postgres-seed"]) { + if (serviceProjection.has(name)) { + serviceProjection.set(name, { + visibility: "internal", + owner: "postgres", + ownerStatusWhileActive: "Initializing", + }); + } } return serviceProjection; @@ -235,7 +241,21 @@ export class StackBuilder extends Context.Service< dockerServicesEnabled, ); const hasPostgresInit = postgresResolution.type === "binary"; - const postgresDeps = dependsOnPostgres(hasPostgresInit); + const initialPostgresDeps = dependsOnPostgres(hasPostgresInit); + const bootstrapRuntime: DatabaseBootstrapRuntime = + postgresResolution.type === "binary" + ? { _tag: "Native", postgresDir: postgresResolution.path } + : { + _tag: "Docker", + containerName: dockerContainerName("postgres", config.apiPort), + }; + const hasMigrationPhase = config.databaseBootstrap.migrationFiles.length > 0; + const hasSeedPhase = config.databaseBootstrap.seedFiles.length > 0; + const postgresDeps: ReadonlyArray = hasSeedPhase + ? [{ service: "postgres-seed", condition: "completed" }] + : hasMigrationPhase + ? [{ service: "postgres-migrations", condition: "completed" }] + : initialPostgresDeps; const jwtJwks = config.credentials.jwks; const defs: Array = [ @@ -275,6 +295,32 @@ export class StackBuilder extends Context.Service< }); } + if (hasMigrationPhase) { + defs.push({ + ...makeDatabaseMigrationService({ + runtime: bootstrapRuntime, + dbPort: config.dbPort, + migrationFiles: config.databaseBootstrap.migrationFiles, + dependencies: initialPostgresDeps, + }), + enabled: true, + }); + } + + if (hasSeedPhase) { + defs.push({ + ...makeDatabaseSeedService({ + runtime: bootstrapRuntime, + dbPort: config.dbPort, + seedFiles: config.databaseBootstrap.seedFiles, + dependencies: hasMigrationPhase + ? [{ service: "postgres-migrations", condition: "completed" }] + : initialPostgresDeps, + }), + enabled: true, + }); + } + if (config.postgrest !== false && postgrestResolution !== false) { defs.push({ ...(postgrestResolution.type === "binary" @@ -286,6 +332,7 @@ export class StackBuilder extends Context.Service< extraSearchPath: config.postgrest.extraSearchPath, maxRows: config.postgrest.maxRows, jwtSecret: config.jwtSecret, + dependencies: postgresDeps, }) : makePostgrestServiceDocker({ image: postgrestResolution.image, @@ -302,12 +349,8 @@ export class StackBuilder extends Context.Service< config.postgrest.adminPort, ]), apiPort: config.apiPort, + dependencies: postgresDeps, })), - ...(hasPostgresInit - ? {} - : { - dependencies: [{ service: "postgres", condition: "healthy" as const }], - }), enabled: true, }); } @@ -636,7 +679,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 e40ec8a693..9050841c08 100644 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ b/packages/stack/src/StackBuilder.unit.test.ts @@ -59,6 +59,7 @@ const baseConfig: ResolvedStackConfig = { autoManagedPaths: [], anonJwt: generateJwt(testJwtSecret, "anon"), serviceRoleJwt: generateJwt(testJwtSecret, "service_role"), + databaseBootstrap: { migrationFiles: [], seedFiles: [] }, postgres: { port: 5432, dataDir: "/tmp/pg-data", @@ -245,6 +246,92 @@ describe("StackBuilder", () => { }).pipe(Effect.provide(layer)); }); + it.effect("gates native database consumers on ordered bootstrap phases", () => { + 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: { + migrationFiles: ["/project/supabase/migrations/20260805000000_init.sql"], + 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-migrations")); + expect(names.indexOf("postgres-migrations")).toBeLessThan(names.indexOf("postgres-seed")); + expect(names.indexOf("postgres-seed")).toBeLessThan(names.indexOf("postgrest")); + expect(service("postgres-migrations")?.dependencies).toEqual([ + { service: "postgres-init", condition: "completed" }, + ]); + expect(service("postgres-seed")?.dependencies).toEqual([ + { service: "postgres-migrations", 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-migrations")).toEqual({ + visibility: "internal", + owner: "postgres", + ownerStatusWhileActive: "Initializing", + }); + expect(serviceProjection.get("postgres-seed")).toEqual({ + visibility: "internal", + owner: "postgres", + ownerStatusWhileActive: "Initializing", + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("runs Docker 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: { + migrationFiles: ["/project/supabase/migrations/20260805000000_app.sql"], + seedFiles: [], + }, + }); + + const migrations = graph.startOrder.find(({ name }) => name === "postgres-migrations"); + expect(migrations?.dependencies).toEqual([{ service: "postgres", condition: "healthy" }]); + expect(migrations?.args).toEqual( + expect.arrayContaining([ + "docker", + "supabase-postgres-3000", + "/project/supabase/migrations/20260805000000_app.sql", + ]), + ); + expect(migrations?.args?.[1]).toContain('cat "$file"'); + expect(migrations?.args?.[1]).toContain("--single-transaction"); + expect(migrations?.args?.[1]).not.toMatch(/docker exec[^\n]*-f/); + expect(graph.startOrder.find(({ name }) => name === "auth")?.dependencies).toEqual([ + { service: "postgres-migrations", condition: "completed" }, + ]); + expect(graph.startOrder.map(({ name }) => name)).not.toContain("postgres-init"); + }).pipe(Effect.provide(layer)); + }); + it.effect("uses docker fallback when auth binary not found", () => { const resolver = mockBinaryResolver({ failServices: ["auth"] }); const layer = builderLayer(resolver); diff --git a/packages/stack/src/StackConfig.ts b/packages/stack/src/StackConfig.ts index 3c269b884f..341c48bd3b 100644 --- a/packages/stack/src/StackConfig.ts +++ b/packages/stack/src/StackConfig.ts @@ -65,6 +65,27 @@ 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 { + /** Conventional timestamped migrations, already ordered by the caller. */ + readonly migrationFiles?: ReadonlyArray; + /** Seed SQL, already expanded, ordered, and fingerprinted by the caller. */ + readonly seedFiles?: ReadonlyArray; +} + +export interface ResolvedDatabaseBootstrapConfig { + readonly migrationFiles: ReadonlyArray; + readonly seedFiles: ReadonlyArray; +} + export interface PostgrestConfig { readonly schemas?: ReadonlyArray; readonly extraSearchPath?: ReadonlyArray; @@ -166,6 +187,7 @@ export interface StackConfig { readonly port?: number; readonly publishableKey?: string; readonly secretKey?: string; + readonly databaseBootstrap?: DatabaseBootstrapConfig; readonly functions?: FunctionsConfig | false; readonly postgres?: PostgresConfig; readonly postgrest?: PostgrestConfig | false; @@ -297,6 +319,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 377ebf3c03..098b23539b 100644 --- a/packages/stack/src/StackConfigResolver.ts +++ b/packages/stack/src/StackConfigResolver.ts @@ -550,6 +550,10 @@ export async function resolveConfig( autoManagedPaths: roots.autoManagedPaths, anonJwt: credentials.anonKey, serviceRoleJwt: credentials.serviceRoleKey, + databaseBootstrap: { + migrationFiles: config.databaseBootstrap?.migrationFiles ?? [], + seedFiles: config.databaseBootstrap?.seedFiles ?? [], + }, postgres: { port: ports.dbPort, dataDir: postgresDataDir, diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index bf80151b47..8b54629925 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -83,6 +83,8 @@ export { ApiProxy } from "./ApiProxy.ts"; export type { AnalyticsConfig, AuthConfig, + DatabaseBootstrapConfig, + DatabaseSeedFile, EdgeRuntimeConfig, ImgproxyConfig, MailpitConfig, @@ -93,6 +95,7 @@ export type { RealtimeConfig, ResolvedAnalyticsConfig, ResolvedAuthConfig, + ResolvedDatabaseBootstrapConfig, ResolvedEdgeRuntimeConfig, ResolvedImgproxyConfig, ResolvedMailpitConfig, diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 8e990d0640..13bdb9411f 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, diff --git a/packages/stack/src/services/database-bootstrap.ts b/packages/stack/src/services/database-bootstrap.ts new file mode 100644 index 0000000000..f705d21c4d --- /dev/null +++ b/packages/stack/src/services/database-bootstrap.ts @@ -0,0 +1,224 @@ +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 DatabaseMigrationServiceOptions { + readonly runtime: DatabaseBootstrapRuntime; + readonly dbPort: number; + readonly migrationFiles: ReadonlyArray; + readonly dependencies: ReadonlyArray; +} + +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`. Each migration/seed payload and its +// history write share one `--single-transaction` session: either both commit or neither does. + +const migrationsScript = ` +set -euo pipefail +${psqlRunner} + +apply_migration() { + file="$1" + version="$2" + name="$3" + if [ "$runtime" = "native" ]; then + run_psql ${psqlOptions} --single-transaction -v migration_version="$version" -v migration_name="$name" -f "$file" -c "INSERT INTO supabase_migrations.schema_migrations(version, name, statements) VALUES (:'migration_version', :'migration_name', ARRAY[]::text[])" + else + { + cat "$file" + printf '\n' + cat <<'EOSQL' +INSERT INTO supabase_migrations.schema_migrations(version, name, statements) VALUES (:'migration_version', :'migration_name', ARRAY[]::text[]); +EOSQL + } | run_psql ${psqlOptions} --single-transaction -v migration_version="$version" -v migration_name="$name" + fi +} + +migration_count="$1" +shift + +if [ "$migration_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.schema_migrations (version text NOT NULL PRIMARY KEY); +ALTER TABLE supabase_migrations.schema_migrations ADD COLUMN IF NOT EXISTS statements text[]; +ALTER TABLE supabase_migrations.schema_migrations ADD COLUMN IF NOT EXISTS name text; +EOSQL +fi + +i=0 +while [ "$i" -lt "$migration_count" ]; do + file="$1" + shift + filename="\${file##*/}" + version="\${filename%%_*}" + name="\${filename#*_}" + name="\${name%.sql}" + applied="$(run_psql ${psqlOptions} -v migration_version="$version" -tAc "SELECT 1 FROM supabase_migrations.schema_migrations WHERE version = :'migration_version'" || true)" + if [ "$applied" != "1" ]; then + latest="$(run_psql ${psqlOptions} -tAc "SELECT coalesce(max(version), '') FROM supabase_migrations.schema_migrations")" + if [ -n "$latest" ] && [[ "$version" < "$latest" ]]; then + echo "Cannot apply an out-of-order local migration." >&2 + exit 1 + fi + echo "Applying migration $filename..." + apply_migration "$file" "$version" "$name" + fi + i=$((i + 1)) +done +`.trim(); + +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 makeDatabaseMigrationService = ( + opts: DatabaseMigrationServiceOptions, +): ServiceDef => ({ + name: "postgres-migrations", + command: "bash", + args: [ + "-c", + migrationsScript, + "postgres-migrations", + ...runtimeArgs(opts.runtime), + String(opts.migrationFiles.length), + ...opts.migrationFiles, + ], + env: runtimeEnv(opts.runtime, opts.dbPort), + dependencies: opts.dependencies, + supervision: {}, + restart: "no", +}); + +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/postgrest.ts b/packages/stack/src/services/postgrest.ts index 0562277927..508ac17f0a 100644 --- a/packages/stack/src/services/postgrest.ts +++ b/packages/stack/src/services/postgrest.ts @@ -1,6 +1,7 @@ import type { ServiceDef } from "@supabase/process-compose"; import { dockerServiceCleanup, dockerServiceOrphanCleanup } from "./docker-cleanup.ts"; import { stackHealthBudgets } from "./health-budgets.ts"; +import type { ServiceDependency } from "./service-utils.ts"; interface PostgrestServiceOptions { readonly dbPort: number; @@ -9,6 +10,7 @@ interface PostgrestServiceOptions { readonly extraSearchPath: ReadonlyArray; readonly maxRows: number; readonly jwtSecret: string; + readonly dependencies: ReadonlyArray; } interface NativePostgrestOptions extends PostgrestServiceOptions { @@ -47,13 +49,11 @@ const postgrestHealthCheck = (port: number) => ({ ...stackHealthBudgets.postgrest, }); -const postgrestDependencies = [{ service: "postgres-init", condition: "completed" as const }]; - export const makePostgrestService = (opts: NativePostgrestOptions): ServiceDef => ({ name: "postgrest", command: `${opts.binPath}/postgrest`, env: postgrestEnv(opts), - dependencies: postgrestDependencies, + dependencies: opts.dependencies, healthCheck: postgrestHealthCheck(opts.port), supervision: {}, restart: "unless-stopped", @@ -71,7 +71,7 @@ export const makePostgrestServiceDocker = (opts: DockerPostgrestOptions): Servic name: "postgrest", command: "docker", args: ["run", "--rm", "--name", containerName, ...opts.networkArgs, ...envArgs, opts.image], - dependencies: postgrestDependencies, + dependencies: opts.dependencies, healthCheck: postgrestHealthCheck(opts.port), cleanup: dockerServiceCleanup(containerName), supervision: { orphanCleanup: dockerServiceOrphanCleanup(containerName) }, diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index 689825811d..492ef153be 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -1,9 +1,11 @@ -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 { makeDatabaseMigrationService, makeDatabaseSeedService } from "./database-bootstrap.ts"; import { makeEdgeRuntimeServiceDocker, makeEdgeRuntimeServiceNative } from "./edge-runtime.ts"; import { makeImgproxyServiceDocker } from "./imgproxy.ts"; import { makeMailpitServiceDocker } from "./mailpit.ts"; @@ -61,6 +63,123 @@ const AUTH_CONFIG = { version: DEFAULT_VERSIONS.auth, }; +describe("database bootstrap services", () => { + it("keeps ordered migration inputs in a completed one-shot phase", () => { + const migration = "/project/supabase/migrations/20260805000000_init.sql"; + const def = makeDatabaseMigrationService({ + runtime: { _tag: "Native", postgresDir: POSTGRES_BIN_PATH }, + dbPort: DB_PORT, + migrationFiles: [migration], + dependencies: [{ service: "postgres-init", condition: "completed" }], + }); + + expect(def).toMatchObject({ + name: "postgres-migrations", + command: "bash", + restart: "no", + dependencies: [{ service: "postgres-init", condition: "completed" }], + env: { + PGPASSWORD: "postgres", + SUPABASE_BOOTSTRAP_DB_PORT: String(DB_PORT), + }, + }); + expect(def.args?.slice(-2)).toEqual(["1", migration]); + expect(def.args?.[1]).toContain("supabase_migrations.schema_migrations"); + }); + + 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-migrations", condition: "completed" }], + }); + + expect(def).toMatchObject({ + name: "postgres-seed", + restart: "no", + dependencies: [{ service: "postgres-migrations", condition: "completed" }], + }); + 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({ @@ -343,6 +462,7 @@ describe("makePostgrestService", () => { extraSearchPath: ["public", "extensions"], maxRows: 1000, jwtSecret: JWT_SECRET, + dependencies: [{ service: "postgres-init", condition: "completed" }], }); expect(def.name).toBe("postgrest"); From c5b915280a126a398825d78dae613d3dc5683ab1 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 17:19:22 +0200 Subject: [PATCH 08/26] test(stack): await stopped lifecycle state --- packages/stack/src/Stack.unit.test.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index 5f40c98cee..4470c7fc85 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -745,8 +745,14 @@ describe("Stack", () => { return Effect.gen(function* () { const coordinator = yield* StackLifecycleCoordinator; yield* coordinator.start(); + const stateChanges = yield* coordinator.stateChanges("auth"); + const stoppedState = yield* stateChanges.pipe( + Stream.filter((state) => state.status === "Stopped"), + Stream.runHead, + Effect.forkChild({ startImmediately: true }), + ); yield* coordinator.stopService("auth"); - yield* Effect.sleep("20 millis"); + yield* Fiber.join(stoppedState); expect((yield* coordinator.getState("auth")).status).toBe("Stopped"); yield* coordinator.stop(); From 7b1cb8026326fe9414e4e07e7fb87262769007a5 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 17:01:48 +0200 Subject: [PATCH 09/26] feat(stack): implement data-plane config parity --- .../config/push/config-sync/storage.sync.ts | 8 +- .../src/legacy/commands/start/lib/db-setup.ts | 6 +- .../start/services/storage.service.ts | 7 +- .../legacy/commands/start/start.handler.ts | 8 +- .../shared/legacy-db-config.toml-read.ts | 4 +- .../legacy-db-config.toml-read.unit.test.ts | 2 +- .../src/legacy/shared/legacy-size-units.ts | 96 +--------- .../shared/legacy-storage-bucket-config.ts | 4 +- .../src/next/config/analytics-stack-config.ts | 71 ++++++++ .../config/data-plane-stack-config-values.ts | 130 ++++++++++++++ .../next/config/data-plane-stack-config.ts | 44 +++++ .../data-plane-stack-config.unit.test.ts | 164 ++++++++++++++++++ .../next/config/local-stack-config-parity.ts | 26 ++- .../local-stack-config-parity.unit.test.ts | 22 ++- .../src/next/config/pooler-stack-config.ts | 31 ++++ .../src/next/config/realtime-stack-config.ts | 25 +++ .../config/stack-config.integration.test.ts | 108 ++++++++++++ apps/cli/src/next/config/stack-config.ts | 26 ++- .../src/next/config/stack-config.unit.test.ts | 3 + .../src/next/config/storage-stack-config.ts | 73 ++++++++ .../src/next/config/studio-stack-config.ts | 16 ++ packages/config/src/index.ts | 1 + packages/config/src/storage-size.ts | 54 ++++++ packages/config/src/storage-size.unit.test.ts | 23 +++ packages/config/src/storage.ts | 2 +- packages/stack/docs/architecture.md | 9 + packages/stack/src/Stack.unit.test.ts | 2 +- packages/stack/src/StackBuilder.ts | 4 + packages/stack/src/StackBuilder.unit.test.ts | 1 + packages/stack/src/StackConfig.ts | 21 +++ packages/stack/src/StackConfigResolver.ts | 6 +- packages/stack/src/services/analytics.ts | 10 +- packages/stack/src/services/realtime.ts | 3 +- .../stack/src/services/services.unit.test.ts | 116 ++++++++++++- packages/stack/src/services/storage.ts | 69 +++++--- packages/stack/src/services/studio.ts | 3 +- 36 files changed, 1040 insertions(+), 158 deletions(-) create mode 100644 apps/cli/src/next/config/analytics-stack-config.ts create mode 100644 apps/cli/src/next/config/data-plane-stack-config-values.ts create mode 100644 apps/cli/src/next/config/data-plane-stack-config.ts create mode 100644 apps/cli/src/next/config/data-plane-stack-config.unit.test.ts create mode 100644 apps/cli/src/next/config/pooler-stack-config.ts create mode 100644 apps/cli/src/next/config/realtime-stack-config.ts create mode 100644 apps/cli/src/next/config/storage-stack-config.ts create mode 100644 apps/cli/src/next/config/studio-stack-config.ts create mode 100644 packages/config/src/storage-size.ts create mode 100644 packages/config/src/storage-size.unit.test.ts 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/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/config/analytics-stack-config.ts b/apps/cli/src/next/config/analytics-stack-config.ts new file mode 100644 index 0000000000..c0602f0ae0 --- /dev/null +++ b/apps/cli/src/next/config/analytics-stack-config.ts @@ -0,0 +1,71 @@ +import type { 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 config: ProjectConfig["analytics"]; + readonly environment: ProjectEnvironment | null; + readonly configDir: string; + readonly base: AnalyticsConfig | false | undefined; +}): AnalyticsConfig | false { + const enabled = resolveBooleanOverride({ + environment: input.environment, + envName: "SUPABASE_ANALYTICS_ENABLED", + configured: input.config.enabled, + path: "analytics.enabled", + }); + const backend = resolveEnumOverride<"postgres" | "bigquery">({ + environment: input.environment, + envName: "SUPABASE_ANALYTICS_BACKEND", + configured: input.config.backend, + path: "analytics.backend", + values: ["postgres", "bigquery"], + }); + const gcp = + enabled && backend === "bigquery" + ? { + projectId: required( + environmentOverride( + "SUPABASE_ANALYTICS_GCP_PROJECT_ID", + input.config.gcp_project_id, + input.environment, + ), + "analytics.gcp_project_id", + ), + projectNumber: required( + environmentOverride( + "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", + input.config.gcp_project_number, + input.environment, + ), + "analytics.gcp_project_number", + ), + credentialsPath: resolve( + input.configDir, + required( + environmentOverride( + "SUPABASE_ANALYTICS_GCP_JWT_PATH", + input.config.gcp_jwt_path, + input.environment, + ), + "analytics.gcp_jwt_path", + ), + ), + } + : undefined; + + return input.base === false ? false : { ...input.base, backend, gcp }; +} 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..bd285e97ac --- /dev/null +++ b/apps/cli/src/next/config/data-plane-stack-config-values.ts @@ -0,0 +1,130 @@ +import type { ProjectEnvironment } from "@supabase/config"; +import { Data } from "effect"; + +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( + name: string, + configured: string | undefined, + environment: ProjectEnvironment | null, +): string | undefined { + const override = environment?.values[name]; + const value = override === undefined || override.length === 0 ? configured : override; + if (value === undefined) return undefined; + + 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; +} + +/** 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; +} + +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 resolveBooleanOverride(input: { + readonly environment: ProjectEnvironment | null; + readonly envName: string; + readonly configured: boolean; + readonly path: string; +}): boolean { + const override = environmentOverride(input.envName, undefined, input.environment); + if (override === undefined) return input.configured; + const value = GO_BOOLEAN_VALUES[override]; + if (value === undefined) { + throw invalidDataPlaneConfig( + input.path, + "Use a Go-compatible boolean such as true, false, 1, or 0.", + ); + } + return value; +} + +function parseBaseZeroUint(value: string): bigint | 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 { + return BigInt(literal); + } catch { + return undefined; + } +} + +export function resolveUintOverride(input: { + readonly environment: ProjectEnvironment | null; + readonly envName: string; + readonly configured: number; + readonly path: string; +}): number { + const override = environmentOverride(input.envName, undefined, input.environment); + if (override === undefined) return input.configured; + const parsed = parseBaseZeroUint(override); + if (parsed === undefined || parsed > 4_294_967_295n) { + throw invalidDataPlaneConfig(input.path, "Use a non-negative 32-bit integer."); + } + return Number(parsed); +} + +export function resolveEnumOverride(input: { + readonly environment: ProjectEnvironment | null; + readonly envName: string; + readonly configured: string; + readonly path: string; + readonly values: ReadonlyArray; +}): Value { + const resolved = environmentOverride(input.envName, input.configured, input.environment); + 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..df46bfbab1 --- /dev/null +++ b/apps/cli/src/next/config/data-plane-stack-config.ts @@ -0,0 +1,44 @@ +import type { 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 projectConfig: ProjectConfig; + readonly projectEnvironment: ProjectEnvironment | null; + readonly configDir: string; + readonly base: StackConfig; +}): StackConfig { + return { + ...input.base, + realtime: resolveRealtimeStackConfig({ + config: input.projectConfig.realtime, + environment: input.projectEnvironment, + base: input.base.realtime, + }), + storage: resolveStorageStackConfig({ + config: input.projectConfig.storage, + environment: input.projectEnvironment, + base: input.base.storage, + }), + analytics: resolveAnalyticsStackConfig({ + config: input.projectConfig.analytics, + environment: input.projectEnvironment, + configDir: input.configDir, + base: input.base.analytics, + }), + studio: resolveStudioStackConfig({ + config: input.projectConfig.studio, + environment: input.projectEnvironment, + base: input.base.studio, + }), + pooler: resolvePoolerStackConfig({ + config: input.projectConfig.db.pooler, + 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..64a58282ea --- /dev/null +++ b/apps/cli/src/next/config/data-plane-stack-config.unit.test.ts @@ -0,0 +1,164 @@ +import { ProjectConfigSchema, 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({ + 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({ + 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({ + projectConfig, + projectEnvironment: environment({ SUPABASE_REALTIME_IP_VERSION: privateValue }), + configDir: "/project/supabase", + base: { realtime: false }, + }), + ).toThrowError(expect.objectContaining({ paths: ["realtime.ip_version"] })); + try { + resolveDataPlaneStackConfig({ + projectConfig, + projectEnvironment: environment({ SUPABASE_REALTIME_IP_VERSION: privateValue }), + configDir: "/project/supabase", + base: { realtime: false }, + }); + } catch (error) { + expect(JSON.stringify(error)).not.toContain(privateValue); + } + }); + + 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({ + 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/local-stack-config-parity.ts b/apps/cli/src/next/config/local-stack-config-parity.ts index 4e7a70c211..0932a6fc49 100644 --- a/apps/cli/src/next/config/local-stack-config-parity.ts +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -91,6 +91,19 @@ const mappedCoreTopologyField: LocalStackConfigParityDecision = { "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", @@ -110,7 +123,6 @@ const mappedAuthSecretRuntimeField: LocalStackConfigParityDecision = { rationale: "The Auth launch translator passes this credential to the stack without retaining it in diagnostics.", }; - const projectIdentityField: LocalStackConfigParityDecision = { _tag: "not-applicable", presence: "raw-document", @@ -420,9 +432,9 @@ const localStackConfigParity = { port: mappedCoreTopologyField, backend: mappedCoreTopologyField, vector_port: unsupportedOptionalRuntimeField, - gcp_project_id: unsupportedOptionalRuntimeField, - gcp_project_number: unsupportedOptionalRuntimeField, - gcp_jwt_path: unsupportedOptionalRuntimeField, + gcp_project_id: mappedOptionalDataPlaneRuntimeField, + gcp_project_number: mappedOptionalDataPlaneRuntimeField, + gcp_jwt_path: mappedOptionalDataPlaneRuntimeField, } satisfies Record, api: { enabled: mappedCoreTopologyField, @@ -490,7 +502,7 @@ const localStackConfigParity = { } satisfies Record, realtime: { enabled: mappedCoreTopologyField, - ip_version: unsupportedRuntimeField, + ip_version: mappedDataPlaneRuntimeField, max_header_length: mappedCoreTopologyField, } satisfies Record, storage: { @@ -518,7 +530,7 @@ const localStackConfigParity = { buckets: unsupportedRuntimeField, } satisfies Record, vector: { - enabled: unsupportedRuntimeField, + enabled: mappedDataPlaneRuntimeField, max_buckets: unsupportedRuntimeField, max_indexes: unsupportedRuntimeField, buckets: unsupportedRuntimeField, @@ -528,7 +540,7 @@ const localStackConfigParity = { enabled: mappedCoreTopologyField, port: mappedCoreTopologyField, api_url: mappedCoreTopologyField, - openai_api_key: unsupportedSecretRuntimeField, + openai_api_key: mappedOptionalDataPlaneRuntimeField, } satisfies Record, experimental: { orioledb_version: unsupportedFutureRuntimeField, 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 index f64774d762..37c55072df 100644 --- 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 @@ -17,9 +17,9 @@ describe("localStackConfigParity", () => { ]), ), ).toEqual({ - mapped: 250, + mapped: 256, "not-applicable": 10, - "unsupported-blocking": 95, + "unsupported-blocking": 89, "unsupported-warning": 6, }); }); @@ -32,6 +32,9 @@ describe("localStackConfigParity", () => { 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", @@ -66,13 +69,16 @@ describe("localStackConfigParity", () => { "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(206); @@ -125,4 +131,16 @@ describe("localStackConfigParity", () => { "remotes", ]); }); + + it("keeps bucket seeding and unconsumed quotas blocking", () => { + const byPath = new Map(entries.map(({ path, decision }) => [path, decision])); + for (const path of [ + "storage.buckets.*.objects_path", + "storage.buckets.*.public", + "storage.analytics.max_namespaces", + "storage.vector.max_buckets", + ]) { + expect(byPath.get(path)?._tag).toBe("unsupported-blocking"); + } + }); }); 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..ffc6dfd2db --- /dev/null +++ b/apps/cli/src/next/config/pooler-stack-config.ts @@ -0,0 +1,31 @@ +import type { 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 config: ProjectConfig["db"]["pooler"]; + readonly environment: ProjectEnvironment | null; + readonly base: PoolerConfig | false | undefined; +}): PoolerConfig | false { + const mode = resolveEnumOverride<"transaction" | "session">({ + environment: input.environment, + envName: "SUPABASE_DB_POOLER_POOL_MODE", + configured: input.config.pool_mode, + path: "db.pooler.pool_mode", + values: ["transaction", "session"], + }); + const defaultPoolSize = resolveUintOverride({ + environment: input.environment, + envName: "SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE", + configured: input.config.default_pool_size, + path: "db.pooler.default_pool_size", + }); + const maxClientConn = resolveUintOverride({ + environment: input.environment, + envName: "SUPABASE_DB_POOLER_MAX_CLIENT_CONN", + 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..fbd8bf0d8b --- /dev/null +++ b/apps/cli/src/next/config/realtime-stack-config.ts @@ -0,0 +1,25 @@ +import type { 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 config: ProjectConfig["realtime"]; + readonly environment: ProjectEnvironment | null; + readonly base: RealtimeConfig | false | undefined; +}): RealtimeConfig | false { + const ipVersion = resolveEnumOverride<"IPv4" | "IPv6">({ + environment: input.environment, + envName: "SUPABASE_REALTIME_IP_VERSION", + configured: input.config.ip_version, + path: "realtime.ip_version", + values: ["IPv4", "IPv6"], + }); + const maxHeaderLength = resolveUintOverride({ + environment: input.environment, + envName: "SUPABASE_REALTIME_MAX_HEADER_LENGTH", + 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 index ad450712be..b1add5b990 100644 --- a/apps/cli/src/next/config/stack-config.integration.test.ts +++ b/apps/cli/src/next/config/stack-config.integration.test.ts @@ -222,4 +222,112 @@ describe("local stack launch config", () => { 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( + resolveLocalStackLaunch({ + 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 }); + } + }); }); diff --git a/apps/cli/src/next/config/stack-config.ts b/apps/cli/src/next/config/stack-config.ts index 6f181c36b9..d27cf88f26 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -17,6 +17,8 @@ import { 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, @@ -367,10 +369,30 @@ export const resolveLocalStackLaunch = Effect.fnUntraced(function* (input: Local message: deprecationWarning, }, ]; + const dataPlaneConfig = yield* Effect.try({ + try: () => + resolveDataPlaneStackConfig({ + 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: { - ...coreConfig, + ...dataPlaneConfig, projectDir: input.projectPaths.projectRoot, readiness, credentials: translatedAuth.credentials, @@ -383,7 +405,7 @@ export const resolveLocalStackLaunch = Effect.fnUntraced(function* (input: Local version: versionedConfig.auth === false ? undefined : versionedConfig.auth?.version, }, postgres: { - ...coreConfig.postgres, + ...dataPlaneConfig.postgres, autoExposeNewTables, startupHealthTimeoutMs: postgresStartupTimeoutMs, }, 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 3a61f9c1bb..a580c425bf 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -297,6 +297,7 @@ describe("resolveLocalStackLaunch", () => { loadedProjectConfig: loaded({ auth: { captcha: { secret: "do-not-leak" } }, api: { tls: { cert_path: "another-private-value" } }, + storage: { buckets: { images: { objects_path: "third-private-value" } } }, }), }).pipe(Effect.exit), ); @@ -304,8 +305,10 @@ describe("resolveLocalStackLaunch", () => { 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("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("warns on explicit warning fields using paths only", async () => { 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..83215e1cf5 --- /dev/null +++ b/apps/cli/src/next/config/storage-stack-config.ts @@ -0,0 +1,73 @@ +import { + parseStorageSizeBytes, + 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 configured: string; + readonly environment: ProjectEnvironment | null; +}): string { + const configured = + environmentOverride("SUPABASE_STORAGE_FILE_SIZE_LIMIT", input.configured, input.environment) ?? + 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 config: ProjectConfig["storage"]; + readonly environment: ProjectEnvironment | null; + readonly base: StorageConfig | false | undefined; +}): StorageConfig | false { + const fileSizeLimit = resolveFileSizeLimit({ + configured: input.config.file_size_limit, + environment: input.environment, + }); + const s3ProtocolEnabled = resolveBooleanOverride({ + environment: input.environment, + envName: "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", + configured: input.config.s3_protocol.enabled, + path: "storage.s3_protocol.enabled", + }); + const vectorBucketsEnabled = resolveBooleanOverride({ + environment: input.environment, + envName: "SUPABASE_STORAGE_VECTOR_ENABLED", + 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..df97544d9f --- /dev/null +++ b/apps/cli/src/next/config/studio-stack-config.ts @@ -0,0 +1,16 @@ +import type { 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 config: ProjectConfig["studio"]; + readonly environment: ProjectEnvironment | null; + readonly base: StudioConfig | false | undefined; +}): StudioConfig | false { + const openAiApiKey = environmentOverride( + "SUPABASE_STUDIO_OPENAI_API_KEY", + input.config.openai_api_key, + input.environment, + ); + return input.base === false ? false : { ...input.base, openAiApiKey }; +} diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index 77af2064ae..d47eedc401 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -42,3 +42,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/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/stack/docs/architecture.md b/packages/stack/docs/architecture.md index fd4cf04e80..d3bb24f9fa 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -49,6 +49,15 @@ functions options, and per-service configuration. `false` disables an optional s 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 two-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 diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index 3d26acd717..f8613e153b 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -469,7 +469,7 @@ describe("Stack", () => { storage: { port: defaultPorts.storagePort, dataDir: "/tmp/supabase/storage", - fileSizeLimit: "50MiB", + fileSizeLimit: "52428800", s3ProtocolEnabled: true, version: DEFAULT_VERSIONS.storage, }, diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index 7a3e42adee..586d53cf0a 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -481,6 +481,7 @@ export class StackBuilder extends Context.Service< encryptionKey: config.realtime.encryptionKey, secretKeyBase: config.realtime.secretKeyBase, maxHeaderLength: config.realtime.maxHeaderLength, + ipVersion: config.realtime.ipVersion, networkArgs: dockerNetworkArgs(platform.os, [config.realtime.port]), dependencies: postgresDeps, }), @@ -507,6 +508,7 @@ export class StackBuilder extends Context.Service< imgproxyUrl: config.imgproxy !== false ? `http://${serviceHost}:${config.imgproxy.port}` : "", s3ProtocolEnabled: config.storage.s3ProtocolEnabled, + vectorRuntime: config.storage.vectorRuntime, networkArgs: dockerNetworkArgs(platform.os, [config.storage.port]), dependencies: postgresDeps, cleanupDataDirOnExit: hasAutoManagedPath(config, config.storage.dataDir), @@ -565,6 +567,7 @@ export class StackBuilder extends Context.Service< dbPort: config.dbPort, apiKey: config.analytics.apiKey, backend: config.analytics.backend, + gcp: config.analytics.gcp, networkArgs: dockerPortMapArgs(platform.os, [ { host: config.analytics.port, container: 4000 }, ]), @@ -647,6 +650,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, networkArgs: dockerNetworkArgs(platform.os, [config.studio.port]), dependencies: config.analytics === false diff --git a/packages/stack/src/StackBuilder.unit.test.ts b/packages/stack/src/StackBuilder.unit.test.ts index 9050841c08..8abe01f947 100644 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ b/packages/stack/src/StackBuilder.unit.test.ts @@ -631,6 +631,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 341c48bd3b..2c62ff27d4 100644 --- a/packages/stack/src/StackConfig.ts +++ b/packages/stack/src/StackConfig.ts @@ -102,6 +102,7 @@ export interface RealtimeConfig { readonly encryptionKey?: string; readonly secretKeyBase?: string; readonly maxHeaderLength?: number; + readonly ipVersion?: "IPv4" | "IPv6"; } export interface EdgeRuntimeConfig { @@ -118,9 +119,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; @@ -145,6 +154,7 @@ export interface PgmetaConfig { export interface StudioConfig { readonly port?: number; readonly apiUrl?: string; + readonly openAiApiKey?: string; readonly version?: string; } @@ -153,6 +163,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 { @@ -230,6 +247,7 @@ export interface ResolvedRealtimeConfig { readonly encryptionKey: string; readonly secretKeyBase: string; readonly maxHeaderLength: number; + readonly ipVersion: "IPv4" | "IPv6"; } export interface ResolvedEdgeRuntimeConfig { @@ -247,6 +265,7 @@ export interface ResolvedStorageConfig { readonly dataDir: string; readonly fileSizeLimit: string; readonly s3ProtocolEnabled: boolean; + readonly vectorRuntime?: StorageVectorRuntimeConfig; } export interface ResolvedImgproxyConfig { @@ -275,6 +294,7 @@ export interface ResolvedStudioConfig { readonly port: number; readonly version: string; readonly apiUrl: string; + readonly openAiApiKey?: string; } export interface ResolvedAnalyticsConfig { @@ -282,6 +302,7 @@ export interface ResolvedAnalyticsConfig { readonly version: string; readonly backend: "postgres" | "bigquery"; readonly apiKey: string; + readonly gcp?: AnalyticsGcpConfig; } export interface ResolvedVectorConfig { diff --git a/packages/stack/src/StackConfigResolver.ts b/packages/stack/src/StackConfigResolver.ts index 098b23539b..1dda5c95f6 100644 --- a/packages/stack/src/StackConfigResolver.ts +++ b/packages/stack/src/StackConfigResolver.ts @@ -299,6 +299,7 @@ function resolveRealtimeConfig( secretKeyBase: cfg.secretKeyBase ?? "EAx3IQ/wRG1v47ZD4NE4/9RzBI8Jmil3x0yhcW4V2NHBP6c2iPIzwjofi2Ep4HIG", maxHeaderLength: cfg.maxHeaderLength ?? 4096, + ipVersion: cfg.ipVersion ?? "IPv4", }; } @@ -339,8 +340,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, }; } @@ -400,6 +402,7 @@ function resolveStudioConfig( port: ports.studioPort, version: cfg.version ?? DEFAULT_VERSIONS.studio, apiUrl: cfg.apiUrl ?? `http://127.0.0.1:${apiPort}`, + openAiApiKey: cfg.openAiApiKey, }; } @@ -415,6 +418,7 @@ function resolveAnalyticsConfig( version: cfg.version ?? DEFAULT_VERSIONS.analytics, backend: cfg.backend ?? "postgres", apiKey: cfg.apiKey ?? "api-key", + gcp: cfg.gcp, }; } diff --git a/packages/stack/src/services/analytics.ts b/packages/stack/src/services/analytics.ts index 9f2f742438..2215bc0a0e 100644 --- a/packages/stack/src/services/analytics.ts +++ b/packages/stack/src/services/analytics.ts @@ -1,6 +1,7 @@ import type { ServiceDef } from "@supabase/process-compose"; 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 networkArgs: ReadonlyArray; 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({ @@ -72,6 +74,10 @@ export const makeAnalyticsServiceDocker = (opts: DockerAnalyticsOptions): Servic containerName: `supabase-analytics-${opts.apiPort}`, image: opts.image, networkArgs: opts.networkArgs, + 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/realtime.ts b/packages/stack/src/services/realtime.ts index a8bd42eacd..7653e6c19a 100644 --- a/packages/stack/src/services/realtime.ts +++ b/packages/stack/src/services/realtime.ts @@ -14,6 +14,7 @@ interface DockerRealtimeOptions { readonly encryptionKey: string; readonly secretKeyBase: string; readonly maxHeaderLength: number; + readonly ipVersion: "IPv4" | "IPv6"; readonly networkArgs: ReadonlyArray; readonly dependencies: ReadonlyArray; } @@ -55,7 +56,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 492ef153be..a87da8e259 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -16,7 +16,12 @@ import { import { makePostgresService, makePostgresServiceDocker } from "./postgres.ts"; import { makePostgrestService } from "./postgrest.ts"; import { makePoolerServiceDocker, poolerContainerPorts } from "./pooler.ts"; -import { LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET } from "./storage.ts"; +import { makeRealtimeServiceDocker } from "./realtime.ts"; +import { + LOCAL_S3_PROTOCOL_ACCESS_KEY_ID, + LOCAL_S3_PROTOCOL_ACCESS_KEY_SECRET, + makeStorageServiceDocker, +} from "./storage.ts"; import { makeStudioServiceDocker } from "./studio.ts"; import { makeVectorServiceDocker } from "./vector.ts"; import { DEFAULT_VERSIONS, dockerImageForService } from "../versions.ts"; @@ -266,6 +271,115 @@ 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", + networkArgs: [], + 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, + networkArgs: [], + 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, + listenPort: 4000, + nodeHost: "0.0.0.0", + 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", + }, + networkArgs: [], + 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", + networkArgs: [], + 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({ diff --git a/packages/stack/src/services/storage.ts b/packages/stack/src/services/storage.ts index 87e21a6ab9..a901378334 100644 --- a/packages/stack/src/services/storage.ts +++ b/packages/stack/src/services/storage.ts @@ -2,6 +2,7 @@ import type { ServiceDef } from "@supabase/process-compose"; 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; @@ -18,6 +19,7 @@ interface DockerStorageOptions { readonly enableImageTransformation: boolean; readonly imgproxyUrl: string; readonly s3ProtocolEnabled: boolean; + readonly vectorRuntime?: StorageVectorRuntimeConfig; readonly networkArgs: ReadonlyArray; readonly dependencies: ReadonlyArray; readonly cleanupDataDirOnExit?: boolean; @@ -42,40 +44,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", containerName: `supabase-storage-${opts.apiPort}`, image: opts.image, networkArgs: opts.networkArgs, 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, dependsOn: 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 4b139abc96..64de31f25d 100644 --- a/packages/stack/src/services/studio.ts +++ b/packages/stack/src/services/studio.ts @@ -18,6 +18,7 @@ interface DockerStudioOptions { readonly analyticsBackend: "postgres" | "bigquery"; readonly analyticsUrl: string; readonly analyticsApiKey: string; + readonly openAiApiKey?: string; readonly networkArgs: ReadonlyArray; readonly dependencies: ReadonlyArray; } @@ -59,7 +60,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", From fb6e62a52a711ea225c4ee3ea6db3e76f2ac9ab4 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 17:30:46 +0200 Subject: [PATCH 10/26] fix(stack): preserve database bootstrap semantics --- .../commands/start/start.integration.test.ts | 18 ++-- .../next/config/database-bootstrap-config.ts | 23 +++-- .../database-bootstrap-config.unit.test.ts | 66 ++++++++++---- .../next/config/local-stack-config-parity.ts | 10 +-- .../local-stack-config-parity.unit.test.ts | 5 +- .../config/stack-config.integration.test.ts | 12 +-- .../src/next/config/stack-config.unit.test.ts | 2 + packages/stack/src/LocalStack.ts | 39 ++++---- packages/stack/src/Stack.unit.test.ts | 60 ++++++++++++- packages/stack/src/StackBuilder.ts | 24 +---- packages/stack/src/StackBuilder.unit.test.ts | 90 +++++++++---------- packages/stack/src/StackConfig.ts | 3 - packages/stack/src/StackConfigResolver.ts | 1 - packages/stack/src/StackStateProjection.ts | 6 +- .../src/StackStateProjection.unit.test.ts | 19 +++- .../stack/src/services/database-bootstrap.ts | 86 +----------------- .../stack/src/services/services.unit.test.ts | 29 +----- 17 files changed, 231 insertions(+), 262 deletions(-) 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 5cbf728335..7258b7a983 100644 --- a/apps/cli/src/next/commands/start/start.integration.test.ts +++ b/apps/cli/src/next/commands/start/start.integration.test.ts @@ -646,21 +646,16 @@ project_id = "not-a-ref" 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", "migrations"), { recursive: true }); - const migration = join(projectRoot, "supabase", "migrations", "20260805000000_start.sql"); + await mkdir(join(projectRoot, "supabase"), { recursive: true }); const seed = join(projectRoot, "supabase", "seed.sql"); - await writeFile(migration, "create table start_bootstrap(id bigint);"); 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.migrations]", - "enabled = true", - "", - "[db.seed]", - "enabled = true", - 'sql_paths = ["./seed.sql"]', - ].join("\n"), + ["[db.seed]", "enabled = true", 'sql_paths = ["./seed.sql"]'].join("\n"), ); const launch = await Effect.runPromise( @@ -683,7 +678,6 @@ project_id = "not-a-ref" }).pipe(Effect.provide(BunServices.layer)), ); - expect(launch.stackConfig.databaseBootstrap?.migrationFiles).toEqual([migration]); expect(launch.stackConfig.databaseBootstrap?.seedFiles?.map(({ path }) => path)).toEqual([ seed, ]); diff --git a/apps/cli/src/next/config/database-bootstrap-config.ts b/apps/cli/src/next/config/database-bootstrap-config.ts index 9eb0a3153b..2176edfe16 100644 --- a/apps/cli/src/next/config/database-bootstrap-config.ts +++ b/apps/cli/src/next/config/database-bootstrap-config.ts @@ -21,7 +21,7 @@ const GO_BOOLEAN_VALUES: Readonly> = { False: false, }; -const migrationFilePattern = /^([0-9]{14})_(.+)\.sql$/; +const migrationFilePattern = /^([0-9]+)_(.*)\.sql$/; function isRecord(value: unknown): value is Readonly> { return typeof value === "object" && value !== null && !Array.isArray(value); @@ -268,7 +268,21 @@ export const translateDatabaseBootstrapConfig = Effect.fnUntraced(function* (inp configured: loaded.config.db.seed.sql_paths, }); - const migrationFiles = migrationsEnabled ? await conventionalMigrationFiles(configDir) : []; + 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({ @@ -280,10 +294,7 @@ export const translateDatabaseBootstrapConfig = Effect.fnUntraced(function* (inp resolvedSeeds.files.map((path) => seedFile(input.projectRoot, path)), ); - const config = - migrationFiles.length === 0 && seedFiles.length === 0 - ? undefined - : { migrationFiles, seedFiles }; + const config = seedFiles.length === 0 ? undefined : { seedFiles }; return { config, warnings: resolvedSeeds.hasUnmatchedPattern 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 index 5f8e74e19b..914ed42c7f 100644 --- a/apps/cli/src/next/config/database-bootstrap-config.unit.test.ts +++ b/apps/cli/src/next/config/database-bootstrap-config.unit.test.ts @@ -46,15 +46,11 @@ function environment( } describe("translateDatabaseBootstrapConfig", () => { - it("resolves conventional migrations and ordered, deduplicated seed inputs", async () => { + 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, "migrations"), { recursive: true }); await mkdir(join(supabaseDir, "seeds", "nested"), { recursive: true }); - await writeFile(join(supabaseDir, "migrations", "20240202000000_second.sql"), "select 2;"); - await writeFile(join(supabaseDir, "migrations", "20240101000000_first.sql"), "select 1;"); - await writeFile(join(supabaseDir, "migrations", "notes.sql"), "select 0;"); 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);"); @@ -62,7 +58,7 @@ describe("translateDatabaseBootstrapConfig", () => { translateDatabaseBootstrapConfig({ loadedProjectConfig: loaded(projectRoot, { db: { - migrations: { enabled: true }, + migrations: { enabled: false }, seed: { enabled: true, sql_paths: ["./seeds", "./seeds/a.sql"] }, }, }), @@ -71,10 +67,6 @@ describe("translateDatabaseBootstrapConfig", () => { }), ); - expect(result.config?.migrationFiles).toEqual([ - join(supabaseDir, "migrations", "20240101000000_first.sql"), - join(supabaseDir, "migrations", "20240202000000_second.sql"), - ]); expect(result.config?.seedFiles?.map(({ historyPath }) => historyPath)).toEqual([ "supabase/seeds/a.sql", "supabase/seeds/nested/b.sql", @@ -88,6 +80,51 @@ describe("translateDatabaseBootstrapConfig", () => { } }); + 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("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"); @@ -130,13 +167,13 @@ describe("translateDatabaseBootstrapConfig", () => { await writeFile(join(supabaseDir, "migrations", "20240101000000_remote.sql"), "select 1;"); const document = { db: { - migrations: { enabled: true }, + migrations: { enabled: false }, seed: { enabled: false }, }, remotes: { staging: { project_id: "abcdefghijklmnopqrst", - db: { migrations: { enabled: true }, seed: { enabled: false } }, + db: { migrations: { enabled: false }, seed: { enabled: false } }, }, }, }; @@ -152,10 +189,7 @@ describe("translateDatabaseBootstrapConfig", () => { }), ); - expect(result.config?.migrationFiles).toEqual([ - join(supabaseDir, "migrations", "20240101000000_remote.sql"), - ]); - expect(result.config?.seedFiles).toEqual([]); + expect(result.config).toBeUndefined(); } finally { await 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 index 0932a6fc49..bd459d6629 100644 --- a/apps/cli/src/next/config/local-stack-config-parity.ts +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -75,12 +75,12 @@ const mappedDatabaseHealthTimeout: LocalStackConfigParityDecision = { "The launch Adapter resolves the legacy environment override, applies the duration to PostgreSQL startup health, and derives the stack readiness deadline from it.", }; -const mappedDatabaseBootstrapField: LocalStackConfigParityDecision = { +const mappedDatabaseSeedField: LocalStackConfigParityDecision = { _tag: "mapped", presence: "raw-document", mappedBy: "start", rationale: - "The launch Adapter expands ordered SQL inputs and the stack executes them as internal PostgreSQL bootstrap phases.", + "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 mappedCoreTopologyField: LocalStackConfigParityDecision = { @@ -464,12 +464,12 @@ const localStackConfigParity = { max_client_conn: mappedCoreTopologyField, } satisfies Record, migrations: { - enabled: mappedDatabaseBootstrapField, + enabled: unsupportedRuntimeField, schema_paths: unsupportedRuntimeField, } satisfies Record, seed: { - enabled: mappedDatabaseBootstrapField, - sql_paths: mappedDatabaseBootstrapField, + enabled: mappedDatabaseSeedField, + sql_paths: mappedDatabaseSeedField, } satisfies Record, settings: dbSettingsParity, network_restrictions: { 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 index 37c55072df..2df7ad4485 100644 --- 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 @@ -17,9 +17,9 @@ describe("localStackConfigParity", () => { ]), ), ).toEqual({ - mapped: 256, + mapped: 255, "not-applicable": 10, - "unsupported-blocking": 89, + "unsupported-blocking": 90, "unsupported-warning": 6, }); }); @@ -43,7 +43,6 @@ describe("localStackConfigParity", () => { "api.port", "api.schemas", "db.health_timeout", - "db.migrations.enabled", "db.pooler.default_pool_size", "db.pooler.enabled", "db.pooler.max_client_conn", diff --git a/apps/cli/src/next/config/stack-config.integration.test.ts b/apps/cli/src/next/config/stack-config.integration.test.ts index b1add5b990..49fa635665 100644 --- a/apps/cli/src/next/config/stack-config.integration.test.ts +++ b/apps/cli/src/next/config/stack-config.integration.test.ts @@ -166,24 +166,19 @@ describe("local stack launch config", () => { } }); - it("resolves database bootstrap inputs before the stack launch is constructed", async () => { + 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, "migrations"), { recursive: true }); await mkdir(join(supabaseDir, "seeds"), { recursive: true }); - const migration = join(supabaseDir, "migrations", "20260805000000_create_widgets.sql"); const seedSecond = join(supabaseDir, "seeds", "02_widgets.sql"); const seedFirst = join(supabaseDir, "seeds", "01_accounts.sql"); - await writeFile(migration, "create table widgets(id bigint primary key);"); 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.migrations]", - "enabled = true", - "", "[db.seed]", "enabled = true", 'sql_paths = ["./seeds/02_widgets.sql", "./seeds/01_accounts.sql"]', @@ -208,9 +203,6 @@ describe("local stack launch config", () => { }), ); - expect(result.stackConfig.databaseBootstrap).toMatchObject({ - migrationFiles: [migration], - }); expect(result.stackConfig.databaseBootstrap?.seedFiles?.map(({ path }) => path)).toEqual([ seedSecond, seedFirst, 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 a580c425bf..436de9dabb 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -297,6 +297,7 @@ describe("resolveLocalStackLaunch", () => { loadedProjectConfig: loaded({ auth: { captcha: { secret: "do-not-leak" } }, api: { tls: { cert_path: "another-private-value" } }, + db: { migrations: { enabled: true } }, storage: { buckets: { images: { objects_path: "third-private-value" } } }, }), }).pipe(Effect.exit), @@ -305,6 +306,7 @@ describe("resolveLocalStackLaunch", () => { 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.enabled"); 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"); diff --git a/packages/stack/src/LocalStack.ts b/packages/stack/src/LocalStack.ts index ebf7ca66ab..a498fa34f8 100644 --- a/packages/stack/src/LocalStack.ts +++ b/packages/stack/src/LocalStack.ts @@ -493,13 +493,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), @@ -544,7 +557,7 @@ export const localStackLayer = ( targets, }: { readonly runtime: RuntimeState; - readonly targets: ReadonlyArray; + readonly targets: ReadonlyArray; }) => Effect.forEach( targets, @@ -561,7 +574,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) @@ -689,26 +702,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, diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index f8613e153b..35ea9e9659 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -61,7 +61,7 @@ const defaultConfig: ResolvedStackConfig = { autoManagedPaths: [], anonJwt: generateJwt(testJwtSecret, "anon"), serviceRoleJwt: generateJwt(testJwtSecret, "service_role"), - databaseBootstrap: { migrationFiles: [], seedFiles: [] }, + databaseBootstrap: { seedFiles: [] }, postgres: { port: 54322, dataDir: "/tmp/supabase/data", @@ -440,7 +440,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; @@ -456,7 +469,50 @@ 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")); }); diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index 586d53cf0a..867e30eda8 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -12,7 +12,6 @@ import { import { analyticsDockerRuntimeNetwork, makeAnalyticsServiceDocker } from "./services/analytics.ts"; import { makeAuthServiceDocker, makeAuthServiceNative } from "./services/auth.ts"; import { - makeDatabaseMigrationService, makeDatabaseSeedService, type DatabaseBootstrapRuntime, } from "./services/database-bootstrap.ts"; @@ -70,7 +69,7 @@ const publicServiceProjection = ( } > = new Map(defs.map((def) => [def.name, { visibility: "public" as const }] as const)); - for (const name of ["postgres-init", "postgres-migrations", "postgres-seed"]) { + for (const name of ["postgres-init", "postgres-seed"]) { if (serviceProjection.has(name)) { serviceProjection.set(name, { visibility: "internal", @@ -249,13 +248,10 @@ export class StackBuilder extends Context.Service< _tag: "Docker", containerName: dockerContainerName("postgres", config.apiPort), }; - const hasMigrationPhase = config.databaseBootstrap.migrationFiles.length > 0; const hasSeedPhase = config.databaseBootstrap.seedFiles.length > 0; const postgresDeps: ReadonlyArray = hasSeedPhase ? [{ service: "postgres-seed", condition: "completed" }] - : hasMigrationPhase - ? [{ service: "postgres-migrations", condition: "completed" }] - : initialPostgresDeps; + : initialPostgresDeps; const jwtJwks = config.credentials.jwks; const defs: Array = [ @@ -295,27 +291,13 @@ export class StackBuilder extends Context.Service< }); } - if (hasMigrationPhase) { - defs.push({ - ...makeDatabaseMigrationService({ - runtime: bootstrapRuntime, - dbPort: config.dbPort, - migrationFiles: config.databaseBootstrap.migrationFiles, - dependencies: initialPostgresDeps, - }), - enabled: true, - }); - } - if (hasSeedPhase) { defs.push({ ...makeDatabaseSeedService({ runtime: bootstrapRuntime, dbPort: config.dbPort, seedFiles: config.databaseBootstrap.seedFiles, - dependencies: hasMigrationPhase - ? [{ service: "postgres-migrations", condition: "completed" }] - : initialPostgresDeps, + dependencies: initialPostgresDeps, }), enabled: true, }); diff --git a/packages/stack/src/StackBuilder.unit.test.ts b/packages/stack/src/StackBuilder.unit.test.ts index 8abe01f947..4e4a02781c 100644 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ b/packages/stack/src/StackBuilder.unit.test.ts @@ -59,7 +59,7 @@ const baseConfig: ResolvedStackConfig = { autoManagedPaths: [], anonJwt: generateJwt(testJwtSecret, "anon"), serviceRoleJwt: generateJwt(testJwtSecret, "service_role"), - databaseBootstrap: { migrationFiles: [], seedFiles: [] }, + databaseBootstrap: { seedFiles: [] }, postgres: { port: 5432, dataDir: "/tmp/pg-data", @@ -246,7 +246,7 @@ describe("StackBuilder", () => { }).pipe(Effect.provide(layer)); }); - it.effect("gates native database consumers on ordered bootstrap phases", () => { + it.effect("gates native database consumers on the seed bootstrap phase", () => { const resolver = mockBinaryResolver(); const layer = builderLayer(resolver); @@ -256,7 +256,6 @@ describe("StackBuilder", () => { const { graph, serviceProjection } = yield* prepareAndBuild(builder, preparation, { ...baseConfig, databaseBootstrap: { - migrationFiles: ["/project/supabase/migrations/20260805000000_init.sql"], seedFiles: [ { path: "/project/supabase/seed.sql", @@ -270,14 +269,10 @@ describe("StackBuilder", () => { 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-migrations")); - expect(names.indexOf("postgres-migrations")).toBeLessThan(names.indexOf("postgres-seed")); + expect(names.indexOf("postgres-init")).toBeLessThan(names.indexOf("postgres-seed")); expect(names.indexOf("postgres-seed")).toBeLessThan(names.indexOf("postgrest")); - expect(service("postgres-migrations")?.dependencies).toEqual([ - { service: "postgres-init", condition: "completed" }, - ]); expect(service("postgres-seed")?.dependencies).toEqual([ - { service: "postgres-migrations", condition: "completed" }, + { service: "postgres-init", condition: "completed" }, ]); expect(service("auth")?.dependencies).toEqual([ { service: "postgres-seed", condition: "completed" }, @@ -285,11 +280,6 @@ describe("StackBuilder", () => { expect(service("postgrest")?.dependencies).toEqual([ { service: "postgres-seed", condition: "completed" }, ]); - expect(serviceProjection.get("postgres-migrations")).toEqual({ - visibility: "internal", - owner: "postgres", - ownerStatusWhileActive: "Initializing", - }); expect(serviceProjection.get("postgres-seed")).toEqual({ visibility: "internal", owner: "postgres", @@ -298,39 +288,47 @@ describe("StackBuilder", () => { }).pipe(Effect.provide(layer)); }); - it.effect("runs Docker 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: { - migrationFiles: ["/project/supabase/migrations/20260805000000_app.sql"], - seedFiles: [], - }, - }); + 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 migrations = graph.startOrder.find(({ name }) => name === "postgres-migrations"); - expect(migrations?.dependencies).toEqual([{ service: "postgres", condition: "healthy" }]); - expect(migrations?.args).toEqual( - expect.arrayContaining([ - "docker", - "supabase-postgres-3000", - "/project/supabase/migrations/20260805000000_app.sql", - ]), - ); - expect(migrations?.args?.[1]).toContain('cat "$file"'); - expect(migrations?.args?.[1]).toContain("--single-transaction"); - expect(migrations?.args?.[1]).not.toMatch(/docker exec[^\n]*-f/); - expect(graph.startOrder.find(({ name }) => name === "auth")?.dependencies).toEqual([ - { service: "postgres-migrations", condition: "completed" }, - ]); - expect(graph.startOrder.map(({ name }) => name)).not.toContain("postgres-init"); - }).pipe(Effect.provide(layer)); - }); + 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("uses docker fallback when auth binary not found", () => { const resolver = mockBinaryResolver({ failServices: ["auth"] }); diff --git a/packages/stack/src/StackConfig.ts b/packages/stack/src/StackConfig.ts index 2c62ff27d4..95091fc24b 100644 --- a/packages/stack/src/StackConfig.ts +++ b/packages/stack/src/StackConfig.ts @@ -75,14 +75,11 @@ export interface DatabaseSeedFile { } export interface DatabaseBootstrapConfig { - /** Conventional timestamped migrations, already ordered by the caller. */ - readonly migrationFiles?: ReadonlyArray; /** Seed SQL, already expanded, ordered, and fingerprinted by the caller. */ readonly seedFiles?: ReadonlyArray; } export interface ResolvedDatabaseBootstrapConfig { - readonly migrationFiles: ReadonlyArray; readonly seedFiles: ReadonlyArray; } diff --git a/packages/stack/src/StackConfigResolver.ts b/packages/stack/src/StackConfigResolver.ts index 1dda5c95f6..dd48ecc65f 100644 --- a/packages/stack/src/StackConfigResolver.ts +++ b/packages/stack/src/StackConfigResolver.ts @@ -555,7 +555,6 @@ export async function resolveConfig( anonJwt: credentials.anonKey, serviceRoleJwt: credentials.serviceRoleKey, databaseBootstrap: { - migrationFiles: config.databaseBootstrap?.migrationFiles ?? [], seedFiles: config.databaseBootstrap?.seedFiles ?? [], }, postgres: { diff --git a/packages/stack/src/StackStateProjection.ts b/packages/stack/src/StackStateProjection.ts index 1d878a5a49..76417e153e 100644 --- a/packages/stack/src/StackStateProjection.ts +++ b/packages/stack/src/StackStateProjection.ts @@ -14,7 +14,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 sameState(a: StackServiceState | undefined, b: StackServiceState): boolean { @@ -40,7 +40,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 7275ba764e..4ec6dd90d1 100644 --- a/packages/stack/src/StackStateProjection.unit.test.ts +++ b/packages/stack/src/StackStateProjection.unit.test.ts @@ -6,7 +6,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, @@ -15,7 +20,7 @@ function rawState(name: string, status: ServiceState["status"], error: string | restartCount: 0, startedAt: null, error, - desired: "running", + desired, }); } @@ -59,6 +64,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/services/database-bootstrap.ts b/packages/stack/src/services/database-bootstrap.ts index f705d21c4d..2af9decf81 100644 --- a/packages/stack/src/services/database-bootstrap.ts +++ b/packages/stack/src/services/database-bootstrap.ts @@ -12,13 +12,6 @@ export type DatabaseBootstrapRuntime = readonly containerName: string; }; -interface DatabaseMigrationServiceOptions { - readonly runtime: DatabaseBootstrapRuntime; - readonly dbPort: number; - readonly migrationFiles: ReadonlyArray; - readonly dependencies: ReadonlyArray; -} - interface DatabaseSeedServiceOptions { readonly runtime: DatabaseBootstrapRuntime; readonly dbPort: number; @@ -54,64 +47,8 @@ const psqlOptions = [ ].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`. Each migration/seed payload and its -// history write share one `--single-transaction` session: either both commit or neither does. - -const migrationsScript = ` -set -euo pipefail -${psqlRunner} - -apply_migration() { - file="$1" - version="$2" - name="$3" - if [ "$runtime" = "native" ]; then - run_psql ${psqlOptions} --single-transaction -v migration_version="$version" -v migration_name="$name" -f "$file" -c "INSERT INTO supabase_migrations.schema_migrations(version, name, statements) VALUES (:'migration_version', :'migration_name', ARRAY[]::text[])" - else - { - cat "$file" - printf '\n' - cat <<'EOSQL' -INSERT INTO supabase_migrations.schema_migrations(version, name, statements) VALUES (:'migration_version', :'migration_name', ARRAY[]::text[]); -EOSQL - } | run_psql ${psqlOptions} --single-transaction -v migration_version="$version" -v migration_name="$name" - fi -} - -migration_count="$1" -shift - -if [ "$migration_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.schema_migrations (version text NOT NULL PRIMARY KEY); -ALTER TABLE supabase_migrations.schema_migrations ADD COLUMN IF NOT EXISTS statements text[]; -ALTER TABLE supabase_migrations.schema_migrations ADD COLUMN IF NOT EXISTS name text; -EOSQL -fi - -i=0 -while [ "$i" -lt "$migration_count" ]; do - file="$1" - shift - filename="\${file##*/}" - version="\${filename%%_*}" - name="\${filename#*_}" - name="\${name%.sql}" - applied="$(run_psql ${psqlOptions} -v migration_version="$version" -tAc "SELECT 1 FROM supabase_migrations.schema_migrations WHERE version = :'migration_version'" || true)" - if [ "$applied" != "1" ]; then - latest="$(run_psql ${psqlOptions} -tAc "SELECT coalesce(max(version), '') FROM supabase_migrations.schema_migrations")" - if [ -n "$latest" ] && [[ "$version" < "$latest" ]]; then - echo "Cannot apply an out-of-order local migration." >&2 - exit 1 - fi - echo "Applying migration $filename..." - apply_migration "$file" "$version" "$name" - fi - i=$((i + 1)) -done -`.trim(); +// 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 @@ -187,25 +124,6 @@ function runtimeEnv(runtime: DatabaseBootstrapRuntime, dbPort: number): Record ({ - name: "postgres-migrations", - command: "bash", - args: [ - "-c", - migrationsScript, - "postgres-migrations", - ...runtimeArgs(opts.runtime), - String(opts.migrationFiles.length), - ...opts.migrationFiles, - ], - env: runtimeEnv(opts.runtime, opts.dbPort), - dependencies: opts.dependencies, - supervision: {}, - restart: "no", -}); - export const makeDatabaseSeedService = (opts: DatabaseSeedServiceOptions): ServiceDef => ({ name: "postgres-seed", command: "bash", diff --git a/packages/stack/src/services/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index a87da8e259..60fb61ca80 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -5,7 +5,7 @@ import path from "node:path"; import { describe, expect, it } from "vitest"; import { analyticsDockerRuntimeNetwork, makeAnalyticsServiceDocker } from "./analytics.ts"; import { makeAuthServiceNative, makeAuthServiceDocker } from "./auth.ts"; -import { makeDatabaseMigrationService, makeDatabaseSeedService } from "./database-bootstrap.ts"; +import { makeDatabaseSeedService } from "./database-bootstrap.ts"; import { makeEdgeRuntimeServiceDocker, makeEdgeRuntimeServiceNative } from "./edge-runtime.ts"; import { makeImgproxyServiceDocker } from "./imgproxy.ts"; import { makeMailpitServiceDocker } from "./mailpit.ts"; @@ -69,29 +69,6 @@ const AUTH_CONFIG = { }; describe("database bootstrap services", () => { - it("keeps ordered migration inputs in a completed one-shot phase", () => { - const migration = "/project/supabase/migrations/20260805000000_init.sql"; - const def = makeDatabaseMigrationService({ - runtime: { _tag: "Native", postgresDir: POSTGRES_BIN_PATH }, - dbPort: DB_PORT, - migrationFiles: [migration], - dependencies: [{ service: "postgres-init", condition: "completed" }], - }); - - expect(def).toMatchObject({ - name: "postgres-migrations", - command: "bash", - restart: "no", - dependencies: [{ service: "postgres-init", condition: "completed" }], - env: { - PGPASSWORD: "postgres", - SUPABASE_BOOTSTRAP_DB_PORT: String(DB_PORT), - }, - }); - expect(def.args?.slice(-2)).toEqual(["1", migration]); - expect(def.args?.[1]).toContain("supabase_migrations.schema_migrations"); - }); - it("passes stable seed history keys and checksums to Docker PostgreSQL", () => { const def = makeDatabaseSeedService({ runtime: { _tag: "Docker", containerName: "supabase-postgres-54321" }, @@ -103,13 +80,13 @@ describe("database bootstrap services", () => { checksum: "a".repeat(64), }, ], - dependencies: [{ service: "postgres-migrations", condition: "completed" }], + dependencies: [{ service: "postgres", condition: "healthy" }], }); expect(def).toMatchObject({ name: "postgres-seed", restart: "no", - dependencies: [{ service: "postgres-migrations", condition: "completed" }], + dependencies: [{ service: "postgres", condition: "healthy" }], }); expect(def.args).toEqual( expect.arrayContaining([ From 538c71b3ffbf7af94163d5ab5a32c0ecae46e02e Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 17:33:24 +0200 Subject: [PATCH 11/26] fix(cli): make migration discovery gate value-sensitive --- .../database-bootstrap-config.unit.test.ts | 44 +++++++++++++++++++ .../next/config/local-stack-config-parity.ts | 10 ++++- .../local-stack-config-parity.unit.test.ts | 12 ++++- .../src/next/config/stack-config.unit.test.ts | 4 +- 4 files changed, 65 insertions(+), 5 deletions(-) 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 index 914ed42c7f..f4c759c1c7 100644 --- a/apps/cli/src/next/config/database-bootstrap-config.unit.test.ts +++ b/apps/cli/src/next/config/database-bootstrap-config.unit.test.ts @@ -103,6 +103,50 @@ describe("translateDatabaseBootstrapConfig", () => { } }); + 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"); diff --git a/apps/cli/src/next/config/local-stack-config-parity.ts b/apps/cli/src/next/config/local-stack-config-parity.ts index bd459d6629..f00ac6a309 100644 --- a/apps/cli/src/next/config/local-stack-config-parity.ts +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -83,6 +83,14 @@ const mappedDatabaseSeedField: LocalStackConfigParityDecision = { "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", @@ -464,7 +472,7 @@ const localStackConfigParity = { max_client_conn: mappedCoreTopologyField, } satisfies Record, migrations: { - enabled: unsupportedRuntimeField, + enabled: mappedDatabaseMigrationsEnabled, schema_paths: unsupportedRuntimeField, } satisfies Record, seed: { 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 index 2df7ad4485..4e524a8e5e 100644 --- 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 @@ -17,9 +17,9 @@ describe("localStackConfigParity", () => { ]), ), ).toEqual({ - mapped: 255, + mapped: 256, "not-applicable": 10, - "unsupported-blocking": 90, + "unsupported-blocking": 89, "unsupported-warning": 6, }); }); @@ -43,6 +43,7 @@ describe("localStackConfigParity", () => { "api.port", "api.schemas", "db.health_timeout", + "db.migrations.enabled", "db.pooler.default_pool_size", "db.pooler.enabled", "db.pooler.max_client_conn", @@ -111,6 +112,13 @@ describe("localStackConfigParity", () => { 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 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 436de9dabb..5d14ef7774 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -297,7 +297,7 @@ describe("resolveLocalStackLaunch", () => { loadedProjectConfig: loaded({ auth: { captcha: { secret: "do-not-leak" } }, api: { tls: { cert_path: "another-private-value" } }, - db: { migrations: { enabled: true } }, + db: { migrations: { schema_paths: ["./private-schema.sql"] } }, storage: { buckets: { images: { objects_path: "third-private-value" } } }, }), }).pipe(Effect.exit), @@ -306,7 +306,7 @@ describe("resolveLocalStackLaunch", () => { 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.enabled"); + 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"); From 73392f3c0b7cad72b85ec46eda3cc63a46107305 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 17:33:50 +0200 Subject: [PATCH 12/26] test(cli): tighten parity classifications --- .../next/config/local-stack-config-parity.ts | 44 +++++++++++++------ .../local-stack-config-parity.unit.test.ts | 8 ++-- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/apps/cli/src/next/config/local-stack-config-parity.ts b/apps/cli/src/next/config/local-stack-config-parity.ts index 7d03314a26..d45b6f8342 100644 --- a/apps/cli/src/next/config/local-stack-config-parity.ts +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -3,32 +3,34 @@ 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. + * `presence` tells the future launch resolver how to determine whether a field + * affects the local runtime. Most schema defaults erase the distinction between + * an omitted field and an explicitly configured default value. Secrets need an + * additional check: generated `env(...)` placeholders that did not resolve and + * secrets inside disabled subtrees do not affect the runtime. */ +type LocalStackConfigParityPresence = "decoded-value" | "effective-secret" | "raw-document"; + type LocalStackConfigParityDecision = | { readonly _tag: "mapped"; - readonly presence: "decoded-value" | "raw-document"; + readonly presence: LocalStackConfigParityPresence; readonly mappedBy: "start" | "functions-dev" | "stack-functions-runtime"; readonly rationale: string; } | { readonly _tag: "not-applicable"; - readonly presence: "decoded-value" | "raw-document"; + readonly presence: LocalStackConfigParityPresence; readonly rationale: string; } | { readonly _tag: "unsupported-blocking"; - readonly presence: "decoded-value" | "raw-document"; + readonly presence: LocalStackConfigParityPresence; readonly rationale: string; } | { readonly _tag: "unsupported-warning"; - readonly presence: "decoded-value" | "raw-document"; + readonly presence: LocalStackConfigParityPresence; readonly rationale: string; }; @@ -62,9 +64,9 @@ const unsupportedOptionalRuntimeField: LocalStackConfigParityDecision = { const unsupportedSecretRuntimeField: LocalStackConfigParityDecision = { _tag: "unsupported-blocking", - presence: "decoded-value", + presence: "effective-secret", rationale: - "An explicitly configured secret changes local runtime credentials but the next stack launch Adapter does not translate it yet.", + "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 mappedAutoExposeNewTables: LocalStackConfigParityDecision = { @@ -80,7 +82,14 @@ const mappedFunctionManifest: LocalStackConfigParityDecision = { presence: "raw-document", mappedBy: "stack-functions-runtime", rationale: - "The current stack functions runtime resolves every configured function entry, including enablement, JWT verification, paths, static files, and environment values.", + "The current stack functions runtime resolves every configured function entry, including enablement, JWT verification, paths, and static files.", +}; + +const unsupportedPerFunctionEnv: LocalStackConfigParityDecision = { + _tag: "unsupported-blocking", + presence: "raw-document", + rationale: + "The current stack functions runtime merges every function environment into one global record, so per-function overrides are not preserved.", }; const functionConfigParity = { @@ -89,7 +98,7 @@ const functionConfigParity = { import_map: mappedFunctionManifest, entrypoint: mappedFunctionManifest, static_files: mappedFunctionManifest, - env: mappedFunctionManifest, + env: unsupportedPerFunctionEnv, } satisfies Record; const mappedFunctionsDevEdgeRuntime: LocalStackConfigParityDecision = { @@ -138,6 +147,13 @@ const authExternalProviderParity = { email_optional: unsupportedRuntimeField, } satisfies Record; +type AuthExternalParity = { + readonly [Provider in keyof ProjectConfig["auth"]["external"]]: Record< + keyof ProjectConfig["auth"]["external"][Provider], + Node + >; +}; + const authHookParity = { enabled: unsupportedRuntimeField, uri: unsupportedOptionalRuntimeField, @@ -174,7 +190,7 @@ const authExternalParity = { spotify: authExternalProviderParity, workos: authExternalProviderParity, zoom: authExternalProviderParity, -} satisfies Record; +} satisfies AuthExternalParity; const authHooksParity = { mfa_verification_attempt: authHookParity, 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 index 9cc94bb536..b94884bcb0 100644 --- 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 @@ -17,9 +17,9 @@ describe("localStackConfigParity", () => { ]), ), ).toEqual({ - mapped: 12, + mapped: 11, "not-applicable": 10, - "unsupported-blocking": 337, + "unsupported-blocking": 338, "unsupported-warning": 6, }); }); @@ -39,7 +39,6 @@ describe("localStackConfigParity", () => { "functions.*", "functions.*.enabled", "functions.*.entrypoint", - "functions.*.env", "functions.*.import_map", "functions.*.static_files", "functions.*.verify_jwt", @@ -61,7 +60,6 @@ describe("localStackConfigParity", () => { "stack-functions-runtime:functions.*.import_map", "stack-functions-runtime:functions.*.entrypoint", "stack-functions-runtime:functions.*.static_files", - "stack-functions-runtime:functions.*.env", ]); }); @@ -75,6 +73,8 @@ describe("localStackConfigParity", () => { expect(byPath.get("storage.image_transformation.enabled")?.presence).toBe("raw-document"); expect(byPath.get("storage.buckets.*")?.presence).toBe("raw-document"); expect(byPath.get("experimental.webhooks.enabled")?.presence).toBe("raw-document"); + expect(byPath.get("auth.external.apple.secret")?.presence).toBe("effective-secret"); + expect(byPath.get("studio.openai_api_key")?.presence).toBe("effective-secret"); }); it("keeps non-runtime project configuration out of StackConfig", () => { From e0dfbf8ab02b9cd7bd4676a2de350af8bc8ad688 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 16:39:23 +0200 Subject: [PATCH 13/26] refactor(stack): make functions configuration explicit --- .../functions/dev/functions-dev-config.ts | 93 +++++- .../dev/functions-dev-config.unit.test.ts | 68 ++++- .../functions/dev/functions-dev-runtime.ts | 19 +- packages/config/src/index.ts | 1 + packages/config/src/project.ts | 9 + packages/stack/README.md | 29 ++ packages/stack/docs/architecture.md | 31 +- packages/stack/docs/detach-mode.md | 6 + packages/stack/package.json | 1 - .../src/DaemonServer.integration.test.ts | 56 +++- packages/stack/src/DaemonServer.ts | 27 +- packages/stack/src/LocalStack.ts | 37 +-- .../stack/src/RemoteStack.integration.test.ts | 43 ++- packages/stack/src/RemoteStack.ts | 14 +- packages/stack/src/Stack.ts | 17 +- packages/stack/src/Stack.unit.test.ts | 92 +++++- packages/stack/src/StackConfig.ts | 6 +- packages/stack/src/StackConfigResolver.ts | 8 +- packages/stack/src/createStack.ts | 6 +- packages/stack/src/effect.ts | 9 +- packages/stack/src/functions.ts | 266 +++++++----------- packages/stack/src/functions.unit.test.ts | 199 ++++++------- packages/stack/src/index.ts | 7 +- .../stack/src/services/edge-runtime-main.ts | 3 +- packages/stack/tests/createStack.e2e.test.ts | 23 +- pnpm-lock.yaml | 3 - 26 files changed, 679 insertions(+), 394 deletions(-) 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 8cd70eb28f..144c71c390 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,6 +1,13 @@ -import { basename, dirname, resolve } from "node:path"; -import type { FunctionsConfig } from "@supabase/stack/effect"; -import { Effect, Option } from "effect"; +import { + inferFunctionsManifest, + loadDotEnvFile, + loadProjectConfig, + loadProjectEnvironment, + resolveProjectSubtree, +} from "@supabase/config"; +import type { ResolvedFunctionsBundle } from "@supabase/stack/effect"; +import { Effect, Option, Redacted } from "effect"; +import { basename, dirname, join, resolve } from "node:path"; import { ProjectHome } from "../../../config/project-home.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; @@ -14,16 +21,80 @@ export interface FunctionsDevWatchPath { readonly names?: ReadonlyArray; } -export function toStackFunctionsConfig(opts: FunctionsDevConfigOptions): FunctionsConfig { - return { - envFile: Option.match(opts.envFile, { - onNone: () => undefined, - onSome: (path) => path, - }), - noVerifyJwt: opts.noVerifyJwt, - }; +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, +) { + const projectHome = yield* ProjectHome; + const runtimeInfo = yield* RuntimeInfo; + const projectEnvironment = yield* loadProjectEnvironment({ + cwd: projectHome.projectRoot, + 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; +}); + export const functionsDevWatchPaths = Effect.fnUntraced(function* (envFile: Option.Option) { const projectHome = yield* ProjectHome; const runtimeInfo = yield* RuntimeInfo; diff --git a/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts b/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts index 983a7a6a5e..6467afdc29 100644 --- a/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts +++ b/apps/cli/src/next/commands/functions/dev/functions-dev-config.unit.test.ts @@ -7,11 +7,7 @@ import { join } from "node:path"; import { Effect, Exit, Layer, Option } from "effect"; import { ProjectHome } from "../../../config/project-home.service.ts"; import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts"; -import { - functionsDevWatchPaths, - toStackFunctionsConfig, - type FunctionsDevConfigOptions, -} from "./functions-dev-config.ts"; +import { functionsDevWatchPaths, resolveFunctionsBundle } from "./functions-dev-config.ts"; import { FunctionsDevEdgeRuntimeDisabledError, resolveFunctionsDevEdgeRuntimeConfig, @@ -61,16 +57,60 @@ describe("functions dev config", () => { expect(connectOrStartFunctionsDevStack).toBeTypeOf("function"); }); - it("converts CLI options to stack Functions config", () => { - const opts: FunctionsDevConfigOptions = { - envFile: Option.some("./custom.env"), - noVerifyJwt: true, - }; + it.live("resolves project functions, environment and absolute paths before stack handoff", () => { + const cwd = makeTempProject(); + + return Effect.gen(function* () { + yield* Effect.tryPromise(() => + mkdir(join(cwd, "supabase", "functions", "hello", "assets"), { recursive: true }), + ); + yield* Effect.tryPromise(() => + writeFile(join(cwd, "supabase", "functions", "hello", "index.ts"), "export {};\n"), + ); + yield* Effect.tryPromise(() => + writeFile(join(cwd, "supabase", "functions", "hello", "deno.json"), "{}\n"), + ); + yield* Effect.tryPromise(() => + writeFile(join(cwd, "supabase", ".env"), "FUNCTION_VALUE=resolved-secret\n"), + ); + yield* Effect.tryPromise(() => writeFile(join(cwd, "custom.env"), "SHARED=custom\n")); + yield* Effect.tryPromise(() => + writeFile( + join(cwd, "supabase", "config.toml"), + `[functions.hello] +verify_jwt = true +entrypoint = "./functions/hello/index.ts" +import_map = "./functions/hello/deno.json" +static_files = ["./functions/hello/assets/*"] + +[functions.hello.env] +FUNCTION_VALUE = "env(FUNCTION_VALUE)" +`, + ), + ); - expect(toStackFunctionsConfig(opts)).toEqual({ - envFile: "./custom.env", - noVerifyJwt: true, - }); + const bundle = yield* resolveFunctionsBundle({ + envFile: Option.some("./custom.env"), + noVerifyJwt: true, + }); + + expect(bundle).toEqual({ + env: { SHARED: "custom" }, + functions: [ + { + name: "hello", + verifyJWT: false, + entrypointPath: join(cwd, "supabase", "functions", "hello", "index.ts"), + importMapPath: join(cwd, "supabase", "functions", "hello", "deno.json"), + staticFiles: [join(cwd, "supabase", "functions", "hello", "assets", "*")], + env: { FUNCTION_VALUE: "resolved-secret" }, + }, + ], + }); + }).pipe( + Effect.ensuring(Effect.tryPromise(() => rm(cwd, { recursive: true, force: true }))), + Effect.provide(projectLayer(cwd)), + ); }); it.live("selects supabase and explicit env directory watch paths", () => { 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 98ca7dd376..8922ec520a 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 @@ -30,7 +30,7 @@ import { RuntimeInfo } from "../../../../shared/runtime/runtime-info.service.ts" import { startStackWithProgress } from "../../../stack/stack.shared.ts"; import { functionsDevWatchPaths, - toStackFunctionsConfig, + resolveFunctionsBundle, type FunctionsDevConfigOptions, type FunctionsDevWatchPath, } from "./functions-dev-config.ts"; @@ -76,7 +76,6 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio projectStateRoot: projectHome.projectHomeDir, name: opts.stack, edgeRuntime: opts.edgeRuntime, - functions: toStackFunctionsConfig(opts), ...versionsFromContext(serviceVersionContext), }, daemonEntryPoint, @@ -180,9 +179,9 @@ function reloadEdgeRuntime( opts: FunctionsDevRuntimeOptions, edgeRuntime: EdgeRuntimeConfig, ) { - return stack.reloadEdgeRuntime({ - edgeRuntime, - functions: toStackFunctionsConfig(opts), + return Effect.gen(function* () { + const functions = yield* resolveFunctionsBundle(opts); + yield* stack.reloadEdgeRuntime({ edgeRuntime, functions }); }); } @@ -248,7 +247,7 @@ export const runFunctionsDevRuntime = Effect.fnUntraced(function* ( } edgeRuntimeState = result.state; yield* output.info("Function files changed. Restarting edge-runtime..."); - yield* stack.reloadFunctions(toStackFunctionsConfig(opts)); + yield* stack.reloadFunctions({ functions: yield* resolveFunctionsBundle(opts) }); }).pipe( Effect.catch((error) => output.error(error instanceof Error ? error.message : String(error)), @@ -266,9 +265,13 @@ export const runFunctionsDevRuntime = Effect.fnUntraced(function* ( if (startedByCommand) { yield* stack.dispose().pipe(Effect.ignore); } else { - yield* stack.reloadFunctions({}).pipe(Effect.ignore); + const functions = yield* resolveFunctionsBundle({ + envFile: Option.none(), + noVerifyJwt: false, + }); + yield* stack.reloadFunctions({ functions }).pipe(Effect.ignore); } - }), + }).pipe(Effect.ignore), ), ); }); diff --git a/packages/config/src/index.ts b/packages/config/src/index.ts index d47eedc401..563165a364 100644 --- a/packages/config/src/index.ts +++ b/packages/config/src/index.ts @@ -32,6 +32,7 @@ export { type ProjectEnvironment, type ResolvedProjectValue, type ResolveProjectOptions, + loadDotEnvFile, loadProjectEnvironment, resolveProjectSubtree, resolveProjectValue, diff --git a/packages/config/src/project.ts b/packages/config/src/project.ts index eb1def2642..28f4c5cd3a 100644 --- a/packages/config/src/project.ts +++ b/packages/config/src/project.ts @@ -166,6 +166,15 @@ function parseDotEnv( }); } +/** Parse one explicit dotenv file without applying ambient or project-local precedence. */ +export const loadDotEnvFile = Effect.fnUntraced(function* (path: string) { + const fs = yield* FileSystem.FileSystem; + if (!(yield* fs.exists(path))) { + return {}; + } + return yield* parseDotEnv(path, yield* fs.readFileString(path)); +}); + function applySource( target: Record, sources: Record, diff --git a/packages/stack/README.md b/packages/stack/README.md index 90dd2425f3..d8c308f54d 100644 --- a/packages/stack/README.md +++ b/packages/stack/README.md @@ -156,6 +156,35 @@ const stack = await createStack({ }); ``` +### Edge Functions + +The stack accepts an explicit, fully resolved Functions bundle. Paths must be absolute and the +caller owns project-file discovery, environment-file parsing, and manifest interpretation: + +```typescript +const stack = await createStack({ + functions: { + env: { SHARED_VALUE: "available to every function" }, + functions: [ + { + name: "hello", + verifyJWT: true, + entrypointPath: "/absolute/project/supabase/functions/hello/index.ts", + importMapPath: null, + staticFiles: [], + env: { FUNCTION_VALUE: "available only to hello" }, + }, + ], + }, +}); +``` + +Per-function environment values override shared values. Stack-owned runtime URLs and credentials +take final precedence. To update the active bundle, call +`reloadFunctions({ functions: nextBundle })`; `reloadFunctions()` preserves and reapplies the most +recent bundle. `reloadEdgeRuntime()` follows the same preservation rule when its optional +`functions` field is omitted. + ## Docker Mode Set `mode: "docker"` to force all services to run in Docker containers, bypassing native binary resolution: diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index 37fa58dbc3..ffb1db8f5c 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -38,7 +38,8 @@ can use the same lifecycle calls against an in-process stack or a detached daemo `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, local credentials, -functions options, and per-service configuration. `false` disables an optional service. +a resolved Edge Functions bundle, and per-service configuration. `false` disables an optional +service. `StackConfigResolver.resolveConfig()`: @@ -215,15 +216,22 @@ Cleanup targets do not belong to `StackInfo`; they are internal runtime metadata ## Functions runtime configuration and reload -The current `functions.ts` Implementation discovers project configuration and function manifests, -resolves paths and environment values, combines them with stack URLs/keys, and writes -`functions-runtime-config.json` under the Edge Runtime workspace. The Edge Runtime factory mounts -or references that file. +Project discovery is outside the stack boundary. A caller supplies a serializable +`ResolvedFunctionsBundle` containing absolute entrypoint, optional import-map, and static-file +paths plus already-resolved shared and per-function environment values. The import-map path is +explicitly nullable. Per-function environment values override shared values; stack-owned runtime +URLs and credentials take final precedence when the worker is created. -`reloadFunctions()` rewrites the file and updates/restarts the Edge Runtime definition. -`reloadEdgeRuntime()` can change runtime settings and optionally functions settings. In detached -mode, `/functions/reload` currently carries `envFile` and `noVerifyJwt` as query parameters, while -`/edge-runtime/reload` accepts a validated JSON body. +`LocalStack` keeps the current bundle in runtime-local memory. `reloadFunctions({ functions })` +replaces it, while a reload without `functions` preserves the latest bundle. An Edge Runtime reload +uses that same current bundle unless its body supplies a replacement. The stack combines the +bundle with runtime URLs and credentials, atomically publishes `functions-runtime-config.json` +with owner-only permissions under the Edge Runtime workspace, and removes it on disposal. + +Detached stacks deliberately exclude resolved bundles from daemon startup IPC, durable metadata, +live state, logs, URLs, and rendered validation errors. Both `/functions/reload` and +`/edge-runtime/reload` accept validated JSON bodies over the local Unix socket. This keeps resolved +environment values confined to an explicit request body and the ephemeral runtime file. ## Port leases @@ -266,8 +274,9 @@ These paths overlap by design and must remain idempotent. Detached mode adds: - `daemonLayer()`: forks a runtime-specific daemon entrypoint and returns a `RemoteStack` layer; -- `daemon.ts`: receives the configuration over Node IPC, resolves ports, builds the foreground - daemon layer, claims live state, and waits for HTTP stop or a signal; +- `daemon.ts`: receives configuration excluding the resolved Functions bundle over Node IPC, + resolves ports, builds the foreground daemon layer, claims live state, and waits for HTTP stop or + a signal; - `DaemonServer`: exposes the `Stack` Interface over HTTP/SSE on a Unix-domain socket; - `RemoteStack`: maps that transport back to the same Effect `Stack` Interface; - `StateManager`: atomically persists and discovers durable metadata and live state. diff --git a/packages/stack/docs/detach-mode.md b/packages/stack/docs/detach-mode.md index e288a7e5a1..f1a1b6ecbd 100644 --- a/packages/stack/docs/detach-mode.md +++ b/packages/stack/docs/detach-mode.md @@ -125,6 +125,12 @@ failures use validated JSON shapes. `RemoteStack` decodes that transport back in `Stack` Interface used in foreground mode, including `ServiceNotFoundError`, `ServiceReadyError`, `StackBuildError`, and `StackReadinessError`. +Functions and Edge Runtime reload routes also use validated JSON bodies. Resolved Functions +bundles may contain environment values, so they are deliberately excluded from daemon startup IPC, +query parameters, durable metadata, live state, logs, and rendered validation errors. The daemon +keeps only the active bundle in memory and writes the derived Edge Runtime file ephemerally with +owner-only permissions. + The management socket is not the public local API endpoint. `ApiProxy` still owns the configured HTTP API port inside the daemon process. diff --git a/packages/stack/package.json b/packages/stack/package.json index 3582370c56..80161f67b9 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -22,7 +22,6 @@ "dependencies": { "@effect/platform-bun": "catalog:", "@effect/platform-node": "catalog:", - "@supabase/config": "workspace:*", "@supabase/process-compose": "workspace:*", "effect": "catalog:" }, diff --git a/packages/stack/src/DaemonServer.integration.test.ts b/packages/stack/src/DaemonServer.integration.test.ts index 1cb2227f68..d7d8664210 100644 --- a/packages/stack/src/DaemonServer.integration.test.ts +++ b/packages/stack/src/DaemonServer.integration.test.ts @@ -5,6 +5,7 @@ import * as http from "node:http"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { DaemonServer } from "./DaemonServer.ts"; import { StackReadinessError } from "./errors.ts"; +import type { FunctionsReloadConfig, ResolvedFunctionsBundle } from "./functions.ts"; import { Stack, type StackInfo } from "./Stack.ts"; import { StackServiceState } from "./StackServiceState.ts"; @@ -57,6 +58,7 @@ const MOCK_LOGS: ReadonlyArray = [ function mockStack(options: { readonly startTimeoutMs?: number } = {}) { let stopped = false; const serviceCalls: string[] = []; + const functionReloads: FunctionsReloadConfig[] = []; const layer = Layer.succeed(Stack, { getInfo: () => Effect.succeed(MOCK_INFO), @@ -96,8 +98,9 @@ function mockStack(options: { readonly startTimeoutMs?: number } = {}) { : Effect.sync(() => { serviceCalls.push(`restart:${name}`); }), - reloadFunctions: () => + reloadFunctions: (config) => Effect.sync(() => { + functionReloads.push(config ?? {}); serviceCalls.push("reload-functions"); }), reloadEdgeRuntime: () => @@ -142,9 +145,24 @@ function mockStack(options: { readonly startTimeoutMs?: number } = {}) { return stopped; }, serviceCalls, + functionReloads, }; } +const functionsBundle: ResolvedFunctionsBundle = { + env: { SHARED_SECRET: "shared-secret-value" }, + functions: [ + { + name: "hello", + verifyJWT: false, + entrypointPath: "/project/supabase/functions/hello/index.ts", + importMapPath: null, + staticFiles: [], + env: { FUNCTION_SECRET: "function-secret-value" }, + }, + ], +}; + // --------------------------------------------------------------------------- // Layer builder // --------------------------------------------------------------------------- @@ -366,6 +384,42 @@ describe("DaemonServer", () => { expect(mock.serviceCalls).toContain("reload-edge-runtime"); }); + test("POST /functions/reload validates and forwards its JSON body", async () => { + const res = await fetch(`${url}/functions/reload`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ functions: functionsBundle }), + }); + + expect(res.status).toBe(200); + expect(mock.functionReloads).toContainEqual({ functions: functionsBundle }); + }); + + test("reload validation never renders resolved environment values", async () => { + const secret = "must-not-appear-in-errors"; + const res = await fetch(`${url}/functions/reload`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + functions: { + env: { SECRET: secret }, + functions: [ + { + ...functionsBundle.functions[0], + entrypointPath: "relative/index.ts", + }, + ], + }, + }), + }); + const responseText = await res.text(); + + expect(res.status).toBe(400); + expect(responseText).toContain("Invalid Edge Functions reload payload"); + expect(responseText).not.toContain(secret); + expect(responseText).not.toContain("relative/index.ts"); + }); + // ------------------------------------------------------------------------- // Error cases — service not found // ------------------------------------------------------------------------- diff --git a/packages/stack/src/DaemonServer.ts b/packages/stack/src/DaemonServer.ts index 4900fcba8c..4fb874f8a5 100644 --- a/packages/stack/src/DaemonServer.ts +++ b/packages/stack/src/DaemonServer.ts @@ -8,6 +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 { EdgeRuntimeReloadConfigSchema, Stack } from "./Stack.ts"; import { ReadyOptionsSchema } from "./StackConfig.ts"; @@ -51,6 +52,11 @@ export class DaemonServer extends Context.Service< ); const buildErrorResponse = (detail: string) => errorResponse({ code: "STACK_BUILD_ERROR", error: detail }, 500); + const invalidReloadPayloadResponse = () => + HttpServerResponse.jsonUnsafe( + { error: "Invalid Edge Functions reload payload" }, + { status: 400 }, + ); const readinessTimeoutResponse = (target: string, timeoutMs: number, detail: string) => errorResponse( { @@ -315,13 +321,14 @@ export class DaemonServer extends Context.Service< "POST", "/functions/reload", Effect.gen(function* () { - const searchParams = yield* HttpServerRequest.ParsedSearchParams; - yield* stack.reloadFunctions({ - envFile: parseSingleParam(searchParams.envFile), - noVerifyJwt: parseBoolean(searchParams.noVerifyJwt), - }); + const body = yield* HttpServerRequest.schemaBodyJson(FunctionsReloadConfigSchema); + yield* stack.reloadFunctions(body); return HttpServerResponse.jsonUnsafe({ ok: true }); }).pipe( + Effect.catchTags({ + SchemaError: () => Effect.succeed(invalidReloadPayloadResponse()), + HttpServerError: () => Effect.succeed(invalidReloadPayloadResponse()), + }), Effect.catchTag("ServiceNotFoundError", (e) => Effect.succeed(notFoundResponse(e.name)), ), @@ -345,6 +352,10 @@ export class DaemonServer extends Context.Service< yield* stack.reloadEdgeRuntime(body); return HttpServerResponse.jsonUnsafe({ ok: true }); }).pipe( + Effect.catchTags({ + SchemaError: () => Effect.succeed(invalidReloadPayloadResponse()), + HttpServerError: () => Effect.succeed(invalidReloadPayloadResponse()), + }), Effect.catchTag("ServiceNotFoundError", (e) => Effect.succeed(notFoundResponse(e.name)), ), @@ -393,9 +404,3 @@ function parseSingleParam(value: string | ReadonlyArray | undefined): st if (value === undefined) return undefined; return typeof value === "string" ? value : value[0]; } - -function parseBoolean(value: string | ReadonlyArray | undefined): boolean | undefined { - const raw = parseSingleParam(value); - if (raw === undefined) return undefined; - return raw === "true"; -} diff --git a/packages/stack/src/LocalStack.ts b/packages/stack/src/LocalStack.ts index c6d6dcbde1..7f507424ef 100644 --- a/packages/stack/src/LocalStack.ts +++ b/packages/stack/src/LocalStack.ts @@ -19,7 +19,11 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import type { CleanupTargets } from "./CleanupTargets.ts"; import { cleanupLocalStackResources } from "./cleanup.ts"; import { StackBuildError, StackNotRunningError, StackReadinessError } from "./errors.ts"; -import { configureFunctionsRuntime, type FunctionsConfig } from "./functions.ts"; +import { + clearFunctionsRuntimeConfig, + configureFunctionsRuntime, + type ResolvedFunctionsBundle, +} from "./functions.ts"; import { detectPlatform, dockerHostAddress } from "./Platform.ts"; import type { PortLease } from "./PortAllocator.ts"; import { @@ -172,6 +176,9 @@ export const localStackLayer = ( const enabledServices = enabledServicesForConfig(config); const stateRef = yield* SubscriptionRef.make(initialPublicStates(config)); const phaseRef = yield* Ref.make("idle"); + const functionsBundleRef = yield* Ref.make( + config.functions === false ? undefined : config.functions, + ); const lifecycleLock = Semaphore.makeUnsafe(1); const logBufferServices = yield* Layer.buildWithScope(LogBuffer.layer, scope); @@ -429,7 +436,8 @@ export const localStackLayer = ( nextConfig: ResolvedStackConfig, ): Effect.Effect => Effect.gen(function* () { - yield* providePlatform(configureFunctionsRuntime(nextConfig, yield* runtimeHost)); + const bundle = yield* Ref.get(functionsBundleRef); + yield* providePlatform(configureFunctionsRuntime(nextConfig, yield* runtimeHost, bundle)); }).pipe( Effect.mapError( (cause) => @@ -439,19 +447,6 @@ export const localStackLayer = ( }), ), ); - const configWithFunctionOptions = (opts?: FunctionsConfig): ResolvedStackConfig => { - if (opts === undefined) { - return config; - } - const base = config.functions === false ? { noVerifyJwt: false } : config.functions; - return { - ...config, - functions: { - envFile: opts.envFile ?? base.envFile, - noVerifyJwt: opts.noVerifyJwt ?? base.noVerifyJwt, - }, - }; - }; const configWithEdgeRuntimeOptions = ( opts: EdgeRuntimeReloadConfig, ): Effect.Effect => @@ -460,9 +455,8 @@ export const localStackLayer = ( return yield* Effect.fail(new ServiceNotFoundError({ name: "edge-runtime" })); } - const base = configWithFunctionOptions(opts.functions); return { - ...base, + ...config, edgeRuntime: { ...config.edgeRuntime, enabled: opts.edgeRuntime.enabled ?? config.edgeRuntime.enabled, @@ -625,6 +619,7 @@ export const localStackLayer = ( cleanupTargets: exactCleanupTargets ?? { dockerContainerNames: [] }, config, }).pipe( + Effect.ensuring(providePlatform(clearFunctionsRuntimeConfig(config.runtimeRoot))), Effect.ensuring(portLease.releaseAll), Effect.ensuring(Ref.set(phaseRef, "disposed")), ); @@ -778,7 +773,10 @@ export const localStackLayer = ( const started = yield* Effect.gen(function* () { yield* requireMutable("reload functions"); yield* requireKnownService("edge-runtime"); - yield* configureFunctions(configWithFunctionOptions(opts)); + if (opts?.functions !== undefined) { + yield* Ref.set(functionsBundleRef, opts.functions); + } + yield* configureFunctions(config); const runtime = yield* ensureRuntime; const state = yield* runtime.orchestrator.getState("edge-runtime"); if (state.desired !== "running") { @@ -798,6 +796,9 @@ export const localStackLayer = ( yield* requireMutable("reload Edge Runtime"); yield* requireKnownService("edge-runtime"); const nextConfig = yield* configWithEdgeRuntimeOptions(opts); + if (opts.functions !== undefined) { + yield* Ref.set(functionsBundleRef, opts.functions); + } const prepared = yield* ensurePrepared; const runtime = yield* ensureRuntime; const buildResult = yield* builder.build(nextConfig, prepared); diff --git a/packages/stack/src/RemoteStack.integration.test.ts b/packages/stack/src/RemoteStack.integration.test.ts index 43930a1a2a..289d3cf64e 100644 --- a/packages/stack/src/RemoteStack.integration.test.ts +++ b/packages/stack/src/RemoteStack.integration.test.ts @@ -5,8 +5,9 @@ import * as http from "node:http"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; import { DaemonServer } from "./DaemonServer.ts"; import { StackBuildError, StackReadinessError } from "./errors.ts"; +import type { FunctionsReloadConfig, ResolvedFunctionsBundle } from "./functions.ts"; import { RemoteStack } from "./RemoteStack.ts"; -import { Stack, type StackInfo } from "./Stack.ts"; +import { Stack, type EdgeRuntimeReloadConfig, type StackInfo } from "./Stack.ts"; import type { ReadyOptions } from "./StackConfig.ts"; import { StackServiceState } from "./StackServiceState.ts"; import { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; @@ -82,6 +83,8 @@ function mockStack( ) { let stopped = false; const serviceCalls: string[] = []; + const functionReloads: FunctionsReloadConfig[] = []; + const edgeRuntimeReloads: EdgeRuntimeReloadConfig[] = []; const readinessCalls: Array<{ readonly target: string; readonly options?: ReadyOptions }> = []; const layer = Layer.succeed(Stack, { @@ -129,12 +132,14 @@ function mockStack( : Effect.sync(() => { serviceCalls.push(`restart:${name}`); }), - reloadFunctions: () => + reloadFunctions: (config) => Effect.sync(() => { + functionReloads.push(config ?? {}); serviceCalls.push("reload-functions"); }), - reloadEdgeRuntime: () => + reloadEdgeRuntime: (config) => Effect.sync(() => { + edgeRuntimeReloads.push(config); serviceCalls.push("reload-edge-runtime"); }), getState: (name: string) => { @@ -200,9 +205,25 @@ function mockStack( }, serviceCalls, readinessCalls, + functionReloads, + edgeRuntimeReloads, }; } +const functionsBundle: ResolvedFunctionsBundle = { + env: { SHARED_SECRET: "shared-secret-value" }, + functions: [ + { + name: "hello", + verifyJWT: false, + entrypointPath: "/project/supabase/functions/hello/index.ts", + importMapPath: null, + staticFiles: [], + env: { FUNCTION_SECRET: "function-secret-value" }, + }, + ], +}; + // --------------------------------------------------------------------------- // Layer builder — DaemonServer backed by mock Stack on TCP port // --------------------------------------------------------------------------- @@ -467,13 +488,27 @@ describe("RemoteStack integration", () => { expect(mock.serviceCalls).toContain("restart:postgres"); }); + test("reloadFunctions transports the validated bundle in a JSON body", async () => { + await clientRuntime.runPromise( + Effect.flatMap(Stack, (stack) => stack.reloadFunctions({ functions: functionsBundle })), + ); + + expect(mock.functionReloads).toEqual([{ functions: functionsBundle }]); + }); + test("reloadEdgeRuntime records the call", async () => { await clientRuntime.runPromise( Effect.flatMap(Stack, (stack) => - stack.reloadEdgeRuntime({ edgeRuntime: { policy: "oneshot" } }), + stack.reloadEdgeRuntime({ + edgeRuntime: { policy: "oneshot" }, + functions: functionsBundle, + }), ), ); expect(mock.serviceCalls).toContain("reload-edge-runtime"); + expect(mock.edgeRuntimeReloads).toEqual([ + { edgeRuntime: { policy: "oneshot" }, functions: functionsBundle }, + ]); }); test("logHistory returns entries", async () => { diff --git a/packages/stack/src/RemoteStack.ts b/packages/stack/src/RemoteStack.ts index dd0f8ca6a4..7bbd6fb72a 100644 --- a/packages/stack/src/RemoteStack.ts +++ b/packages/stack/src/RemoteStack.ts @@ -337,15 +337,11 @@ export const RemoteStack = { reloadFunctions: (opts) => withUnixHttpClient( Effect.gen(function* () { - const response = yield* unixResponse( - socketPath, - `/functions/reload${encodeSearchParams({ - envFile: opts?.envFile, - noVerifyJwt: - opts?.noVerifyJwt === undefined ? undefined : String(opts.noVerifyJwt), - })}`, - { method: "POST" }, - ); + const response = yield* unixResponse(socketPath, "/functions/reload", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(opts ?? {}), + }); yield* expectDaemonOk(response, "edge-runtime"); }), ), diff --git a/packages/stack/src/Stack.ts b/packages/stack/src/Stack.ts index 6d07355597..9693b08d02 100644 --- a/packages/stack/src/Stack.ts +++ b/packages/stack/src/Stack.ts @@ -2,7 +2,11 @@ import { ServiceNotFoundError } from "@supabase/process-compose"; import type { LogEntry, ServiceReadyError } from "@supabase/process-compose"; import { Context, Effect, Schema, Stream } from "effect"; import { StackBuildError, StackReadinessError } from "./errors.ts"; -import type { FunctionsConfig } from "./functions.ts"; +import { + ResolvedFunctionsBundleSchema, + type FunctionsReloadConfig, + type ResolvedFunctionsBundle, +} from "./functions.ts"; import type { EdgeRuntimeConfig, ReadyOptions } from "./StackConfig.ts"; import { StackServiceState } from "./StackServiceState.ts"; @@ -33,19 +37,14 @@ const EdgeRuntimeConfigSchema = Schema.Struct({ env: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }); -const FunctionsConfigSchema = Schema.Struct({ - envFile: Schema.optionalKey(Schema.String), - noVerifyJwt: Schema.optionalKey(Schema.Boolean), -}); - export const EdgeRuntimeReloadConfigSchema = Schema.Struct({ edgeRuntime: EdgeRuntimeConfigSchema, - functions: Schema.optionalKey(FunctionsConfigSchema), + functions: Schema.optionalKey(ResolvedFunctionsBundleSchema), }); export interface EdgeRuntimeReloadConfig { readonly edgeRuntime: EdgeRuntimeConfig; - readonly functions?: FunctionsConfig; + readonly functions?: ResolvedFunctionsBundle; } export class Stack extends Context.Service< @@ -74,7 +73,7 @@ export class Stack extends Context.Service< ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError >; readonly reloadFunctions: ( - opts?: FunctionsConfig, + opts?: FunctionsReloadConfig, ) => Effect.Effect< void, ServiceNotFoundError | ServiceReadyError | StackBuildError | StackReadinessError diff --git a/packages/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index 53559ba238..cf8bf77ba8 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -2,11 +2,16 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { buildGraph } from "@supabase/process-compose"; import { createHmac } from "node:crypto"; +import { mkdtempSync } from "node:fs"; +import { readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; 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"; import { Stack } from "./Stack.ts"; @@ -135,6 +140,20 @@ const edgeRuntimeConfig: ResolvedStackConfig = { }, }; +const functionsBundle = (root: string, value: string): ResolvedFunctionsBundle => ({ + env: { SHARED: value }, + functions: [ + { + name: "hello", + verifyJWT: false, + entrypointPath: join(root, "hello", "index.ts"), + importMapPath: null, + staticFiles: [], + env: { FUNCTION_VALUE: value }, + }, + ], +}); + const noopPortLease = (ports: AllocatedPorts): PortLease => ({ ports, reserve: () => Effect.void, @@ -187,6 +206,75 @@ describe("Stack", () => { }).pipe(Effect.provide(layer)); }); + 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"); + const replacementBundle = functionsBundle(runtimeRoot, "replacement-secret"); + const config = { + ...edgeRuntimeConfig, + runtimeRoot, + functions: initialBundle, + } satisfies ResolvedStackConfig; + const graph = Effect.runSync( + buildGraph([ + { + name: "edge-runtime", + command: process.execPath, + restart: "unless-stopped", + }, + ]), + ); + const builderLayer = Layer.succeed(StackBuilder, { + build: () => + Effect.succeed({ + graph, + cleanupTargets: { dockerContainerNames: [] }, + serviceProjection: new Map([["edge-runtime", { visibility: "public" as const }]]), + }), + }); + const resolver = mockBinaryResolver(); + 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(mockChildProcessSpawner().layer), + Layer.provide(BunServices.layer), + ); + const readRuntimeConfig = Effect.promise(() => + readFile(functionsRuntimeConfigPath(runtimeRoot), "utf8").then((contents) => + JSON.parse(contents), + ), + ); + + return Effect.gen(function* () { + const stack = yield* Stack; + yield* stack.start(); + + yield* stack.reloadFunctions({ functions: replacementBundle }); + expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); + + yield* stack.reloadEdgeRuntime({ edgeRuntime: { policy: "oneshot" } }); + expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); + + yield* stack.reloadFunctions(); + expect((yield* readRuntimeConfig).env.SHARED).toBe("replacement-secret"); + + yield* stack.dispose(); + expect( + yield* Effect.promise(() => + readFile(functionsRuntimeConfigPath(runtimeRoot), "utf8").then( + () => true, + () => false, + ), + ), + ).toBe(false); + }).pipe( + Effect.provide(layer), + Effect.ensuring(Effect.promise(() => rm(runtimeRoot, { recursive: true, force: true }))), + Effect.timeout("5 seconds"), + ); + }); + it.effect("getInfo returns valid JWT tokens", () => { const { layer } = setupLayer(); @@ -804,7 +892,7 @@ describe("Stack", () => { const config = { ...defaultConfig, startupMode: "lazy", - readiness: { mode: "finite", timeoutMs: 250 }, + readiness: { mode: "finite", timeoutMs: 1_000 }, } satisfies ResolvedStackConfig; const lease: PortLease = { ...noopPortLease(config.ports), @@ -824,7 +912,7 @@ describe("Stack", () => { expect(error._tag).toBe("StackReadinessError"); if (error._tag === "StackReadinessError") { expect(error.target).toBe("auth"); - expect(error.timeoutMs).toBe(250); + expect(error.timeoutMs).toBe(1_000); } expect(releasedAll).toBe(true); const spawnCountAfterDisposal = spawner.spawned.length; diff --git a/packages/stack/src/StackConfig.ts b/packages/stack/src/StackConfig.ts index 95091fc24b..a770414095 100644 --- a/packages/stack/src/StackConfig.ts +++ b/packages/stack/src/StackConfig.ts @@ -1,6 +1,6 @@ import { Schema } from "effect"; import type { AuthRuntimeConfig, ResolvedAuthRuntimeConfig } from "./AuthConfig.ts"; -import type { FunctionsConfig, ResolvedFunctionsConfig } from "./functions.ts"; +import type { ResolvedFunctionsBundle } from "./functions.ts"; import type { LocalCredentials, ResolvedLocalCredentials } from "./LocalCredentials.ts"; import type { AllocatedPorts } from "./PortAllocator.ts"; @@ -202,7 +202,7 @@ export interface StackConfig { readonly publishableKey?: string; readonly secretKey?: string; readonly databaseBootstrap?: DatabaseBootstrapConfig; - readonly functions?: FunctionsConfig | false; + readonly functions?: ResolvedFunctionsBundle | false; readonly postgres?: PostgresConfig; readonly postgrest?: PostgrestConfig | false; readonly auth?: AuthConfig | false; @@ -333,7 +333,7 @@ export interface ResolvedStackConfig { readonly dbPort: number; readonly publishableKey: string; readonly secretKey: string; - readonly functions: ResolvedFunctionsConfig | false; + readonly functions: ResolvedFunctionsBundle | false; readonly autoManagedPaths: ReadonlyArray; readonly anonJwt: string; readonly serviceRoleJwt: string; diff --git a/packages/stack/src/StackConfigResolver.ts b/packages/stack/src/StackConfigResolver.ts index dd48ecc65f..d3fb5b3c5c 100644 --- a/packages/stack/src/StackConfigResolver.ts +++ b/packages/stack/src/StackConfigResolver.ts @@ -321,11 +321,7 @@ function resolveEdgeRuntimeConfig( } function resolveFunctionsConfig(config: StackConfig) { - if (config.functions === false) return false; - return { - envFile: config.functions?.envFile, - noVerifyJwt: config.functions?.noVerifyJwt ?? false, - }; + return config.functions ?? false; } function resolveStorageConfig( @@ -594,7 +590,7 @@ export async function resolveConfig( }; } -export type DaemonConfigInput = StackConfig & { +export type DaemonConfigInput = Omit & { readonly cwd: string; readonly name?: string; readonly projectDir?: string; diff --git a/packages/stack/src/createStack.ts b/packages/stack/src/createStack.ts index f75f6bf66d..fc89fb2edb 100644 --- a/packages/stack/src/createStack.ts +++ b/packages/stack/src/createStack.ts @@ -5,7 +5,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 { FunctionsConfig } from "./functions.ts"; +import type { FunctionsReloadConfig } from "./functions.ts"; import { daemonLayer, foregroundLayer, type DaemonStartError } from "./layers.ts"; import { PORT_FIELDS, reservePorts, type PortLease } from "./PortAllocator.ts"; import { allocatedPortFieldsForConfig } from "./ServicePorts.ts"; @@ -43,7 +43,7 @@ export interface StackHandle extends AsyncDisposable { startService(name: string): Promise; stopService(name: string): Promise; restartService(name: string): Promise; - reloadFunctions(opts?: FunctionsConfig): Promise; + reloadFunctions(opts?: FunctionsReloadConfig): Promise; reloadEdgeRuntime(opts: EdgeRuntimeReloadConfig): Promise; ready(opts?: ReadyOptions): Promise; serviceReady(name: string, opts?: ReadyOptions): Promise; @@ -62,7 +62,7 @@ export const projectDaemonLayer = (opts: { readonly projectStateRoot?: string; readonly name?: string; readonly daemonEntryPoint: string; - readonly stackConfig?: Omit; + readonly stackConfig?: Omit; }): Effect.Effect< Layer.Layer, DaemonStartError | InvalidStackStateError | StackAlreadyRunningError, diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index 8b54629925..5fe331ce8c 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -121,14 +121,19 @@ export { StackBuilder } from "./StackBuilder.ts"; export type { EdgeRuntimeReloadConfig, StackInfo } from "./Stack.ts"; export { EdgeRuntimeReloadConfigSchema, Stack } from "./Stack.ts"; export type { - FunctionsConfig, + FunctionsReloadConfig, FunctionsRuntimeConfig, - ResolvedFunctionsConfig, + ResolvedFunction, + ResolvedFunctionsBundle, } from "./functions.ts"; export { + clearFunctionsRuntimeConfig, configureFunctionsRuntime, + FunctionsReloadConfigSchema, functionsRuntimeConfigFileName, functionsRuntimeConfigPath, + ResolvedFunctionSchema, + ResolvedFunctionsBundleSchema, resolveFunctionsRuntimeConfig, } from "./functions.ts"; diff --git a/packages/stack/src/functions.ts b/packages/stack/src/functions.ts index fd95607f93..bcf5c1648e 100644 --- a/packages/stack/src/functions.ts +++ b/packages/stack/src/functions.ts @@ -1,24 +1,66 @@ -import { readFileSync } from "node:fs"; -import { isAbsolute, join, resolve } from "node:path"; -import { - inferFunctionsManifest, - loadProjectConfig, - loadProjectEnvironment, - resolveProjectSubtree, - type ResolvedFunctionConfig, -} from "@supabase/config"; -import { Effect, FileSystem, Path, Redacted } from "effect"; +import { isAbsolute, join } from "node:path"; +import { Effect, FileSystem, Path, Schema } from "effect"; import type { ResolvedStackConfig } from "./StackConfig.ts"; -export interface FunctionsConfig { - readonly envFile?: string; - readonly noVerifyJwt?: boolean; -} +const absolutePath = Schema.String.check( + Schema.makeFilter((value) => + isAbsolute(value) ? undefined : { path: [], issue: "Expected an absolute path" }, + ), +); + +const environment = Schema.Record(Schema.String, Schema.String); + +export const ResolvedFunctionSchema = Schema.Struct({ + name: Schema.String.check(Schema.isPattern(/^[A-Za-z0-9_-]+$/)), + verifyJWT: Schema.Boolean, + entrypointPath: absolutePath, + importMapPath: Schema.NullOr(absolutePath), + staticFiles: Schema.Array(absolutePath), + env: environment, +}); -export interface ResolvedFunctionsConfig { - readonly envFile?: string; - readonly noVerifyJwt: boolean; -} +export interface ResolvedFunction extends Schema.Schema.Type {} + +/** + * Project-owned Edge Functions input. Every path and environment reference is + * resolved before the bundle crosses into the stack package. + * + * `env` contains values shared by every function. A function's own `env` + * overrides matching shared values when its worker is created. + */ +export const ResolvedFunctionsBundleSchema = Schema.Struct({ + env: environment, + functions: Schema.Array(ResolvedFunctionSchema), +}).check( + Schema.makeFilter((bundle) => { + const names = new Set(); + for (let index = 0; index < bundle.functions.length; index += 1) { + const name = bundle.functions[index]?.name; + if (name !== undefined && names.has(name)) { + return { + path: ["functions", index, "name"], + issue: `Duplicate function name: ${name}`, + }; + } + if (name !== undefined) { + names.add(name); + } + } + return undefined; + }), +); + +export interface ResolvedFunctionsBundle extends Schema.Schema.Type< + typeof ResolvedFunctionsBundleSchema +> {} + +export const FunctionsReloadConfigSchema = Schema.Struct({ + functions: Schema.optionalKey(ResolvedFunctionsBundleSchema), +}); + +export interface FunctionsReloadConfig extends Schema.Schema.Type< + typeof FunctionsReloadConfigSchema +> {} export interface FunctionsRuntimeConfig { readonly functionsUrl: string; @@ -34,8 +76,9 @@ export interface FunctionsRuntimeConfig { { readonly verifyJWT: boolean; readonly entrypointPath: string; - readonly importMapPath: string; + readonly importMapPath: string | null; readonly staticFiles: ReadonlyArray; + readonly env: Readonly>; } > >; @@ -55,142 +98,15 @@ export function functionsRuntimeConfigPath(runtimeRoot: string): string { return join(edgeRuntimeWorkspaceDir(runtimeRoot), functionsRuntimeConfigFileName); } -function reveal(value: string | Redacted.Redacted): string { - return Redacted.isRedacted(value) ? Redacted.value(value) : value; -} - -function absolutizeProjectPath(projectDir: string, relativePath: string): string { - if (relativePath.length === 0) { - return ""; - } - - const withoutDotSlash = relativePath.startsWith("./") ? relativePath.slice(2) : relativePath; - return isAbsolute(withoutDotSlash) - ? withoutDotSlash - : join(projectDir, "supabase", withoutDotSlash); -} - -function parseDotEnv(contents: string): Record { - const env: Record = {}; - const lines = contents.replace(/\r\n?/g, "\n").split("\n"); - - for (const line of lines) { - const trimmed = line.trim(); - if (trimmed === "" || trimmed.startsWith("#")) { - continue; - } - - const equals = line.indexOf("="); - if (equals === -1) { - continue; - } - - const key = line - .slice(0, equals) - .trim() - .replace(/^export\s+/, ""); - let value = line.slice(equals + 1).trim(); - const quote = value[0]; - if ( - (quote === '"' || quote === "'" || quote === "`") && - value.endsWith(quote) && - value.length >= 2 - ) { - value = value.slice(1, -1); - } - if (quote === '"') { - value = value.replace(/\\n/g, "\n").replace(/\\r/g, "\r"); - } - env[key] = value; - } - - return env; -} - -function loadEnvFile(path: string): Record { - try { - return parseDotEnv(readFileSync(path, "utf8")); - } catch { - return {}; - } -} - -const resolveFunctionsProjectConfig = Effect.fnUntraced(function* (projectDir: string) { - const projectEnv = yield* loadProjectEnvironment({ cwd: projectDir, baseEnv: process.env }); - const loadedConfig = yield* loadProjectConfig(projectDir); - if (projectEnv === null || loadedConfig === null) { - return undefined; - } - - const resolvedFunctions = yield* resolveProjectSubtree( - loadedConfig.config.functions, - projectEnv, - "functions", - ); - - return { - ...loadedConfig.config, - functions: Object.fromEntries( - Object.entries(resolvedFunctions).map(([slug, config]) => [ - slug, - { - ...config, - entrypoint: reveal(config.entrypoint), - import_map: reveal(config.import_map), - static_files: config.static_files.map((path) => reveal(path)), - env: Object.fromEntries( - Object.entries(config.env).map(([name, value]) => [name, reveal(value)]), - ), - }, - ]), - ), - }; -}); - -function functionToRuntimeConfig( - projectDir: string, - noVerifyJwt: boolean, - config: ResolvedFunctionConfig, -) { - return { - verifyJWT: noVerifyJwt ? false : config.verify_jwt, - entrypointPath: absolutizeProjectPath(projectDir, config.entrypoint), - importMapPath: absolutizeProjectPath(projectDir, config.import_map), - staticFiles: config.static_files.map((path) => absolutizeProjectPath(projectDir, path)), - }; -} - -export const resolveFunctionsRuntimeConfig = Effect.fnUntraced(function* ( +export function resolveFunctionsRuntimeConfig( stackConfig: ResolvedStackConfig, runtimeHost: FunctionsRuntimeHost, -) { - const functionsConfig = stackConfig.functions; - if (functionsConfig === false || stackConfig.edgeRuntime === false) { - return undefined; - } - - const projectConfig = yield* resolveFunctionsProjectConfig(stackConfig.projectDir); - const manifest = yield* inferFunctionsManifest({ - cwd: stackConfig.projectDir, - ...(projectConfig === undefined ? {} : { config: projectConfig }), - }); - const enabledManifest = Object.entries(manifest).filter(([, config]) => config.enabled); - if (enabledManifest.length === 0) { + bundle: ResolvedFunctionsBundle | undefined, +): FunctionsRuntimeConfig | undefined { + if (bundle === undefined || bundle.functions.length === 0 || stackConfig.edgeRuntime === false) { return undefined; } - const functionEnv = Object.fromEntries( - enabledManifest.flatMap(([, config]) => Object.entries(config.env)), - ); - const envFilePath = - functionsConfig.envFile === undefined - ? join(stackConfig.projectDir, "supabase", "functions", ".env") - : resolve(stackConfig.projectDir, functionsConfig.envFile); - const env = { - ...loadEnvFile(envFilePath), - ...functionEnv, - }; - return { functionsUrl: `http://127.0.0.1:${stackConfig.apiPort}/functions/v1`, supabaseUrl: `http://${runtimeHost.hostname}:${stackConfig.apiPort}`, @@ -198,15 +114,21 @@ export const resolveFunctionsRuntimeConfig = Effect.fnUntraced(function* ( publishableKey: stackConfig.publishableKey, secretKey: stackConfig.secretKey, jwtSecret: stackConfig.jwtSecret, - env, + env: bundle.env, functions: Object.fromEntries( - enabledManifest.map(([slug, config]) => [ - slug, - functionToRuntimeConfig(stackConfig.projectDir, functionsConfig.noVerifyJwt, config), + bundle.functions.map((fn) => [ + fn.name, + { + verifyJWT: fn.verifyJWT, + entrypointPath: fn.entrypointPath, + importMapPath: fn.importMapPath, + staticFiles: fn.staticFiles, + env: fn.env, + }, ]), ), - } satisfies FunctionsRuntimeConfig; -}); + }; +} const writeFunctionsRuntimeConfig = Effect.fnUntraced(function* ( runtimeRoot: string, @@ -215,20 +137,42 @@ const writeFunctionsRuntimeConfig = Effect.fnUntraced(function* ( const fs = yield* FileSystem.FileSystem; const path = yield* Path.Path; const filePath = functionsRuntimeConfigPath(runtimeRoot); - yield* fs.makeDirectory(path.dirname(filePath), { recursive: true }); - yield* fs.writeFileString(filePath, `${JSON.stringify(config, null, 2)}\n`); + const directory = path.dirname(filePath); + const temporaryPath = `${filePath}.tmp-${crypto.randomUUID()}`; + + yield* fs.makeDirectory(directory, { recursive: true, mode: 0o700 }); + yield* Effect.gen(function* () { + yield* fs.writeFileString(temporaryPath, `${JSON.stringify(config, null, 2)}\n`, { + flag: "wx", + mode: 0o600, + }); + yield* fs.chmod(temporaryPath, 0o600); + yield* fs.rename(temporaryPath, filePath); + yield* fs.chmod(filePath, 0o600); + }).pipe(Effect.ensuring(fs.remove(temporaryPath).pipe(Effect.ignore))); }); -const clearFunctionsRuntimeConfig = Effect.fnUntraced(function* (runtimeRoot: string) { +export const clearFunctionsRuntimeConfig = Effect.fnUntraced(function* (runtimeRoot: string) { const fs = yield* FileSystem.FileSystem; - yield* fs.remove(functionsRuntimeConfigPath(runtimeRoot)).pipe(Effect.ignore); + const filePath = functionsRuntimeConfigPath(runtimeRoot); + const directory = yield* Path.Path.pipe(Effect.map((path) => path.dirname(filePath))); + + yield* fs.remove(filePath).pipe(Effect.ignore); + + const entries = yield* fs.readDirectory(directory).pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach( + entries.filter((entry) => entry.startsWith(`${functionsRuntimeConfigFileName}.tmp-`)), + (entry) => fs.remove(join(directory, entry)).pipe(Effect.ignore), + { discard: true }, + ); }); export const configureFunctionsRuntime = Effect.fnUntraced(function* ( stackConfig: ResolvedStackConfig, runtimeHost: FunctionsRuntimeHost, + bundle: ResolvedFunctionsBundle | undefined, ) { - const runtimeConfig = yield* resolveFunctionsRuntimeConfig(stackConfig, runtimeHost); + const runtimeConfig = resolveFunctionsRuntimeConfig(stackConfig, runtimeHost, bundle); if (runtimeConfig === undefined) { yield* clearFunctionsRuntimeConfig(stackConfig.runtimeRoot); } else { diff --git a/packages/stack/src/functions.unit.test.ts b/packages/stack/src/functions.unit.test.ts index e537ea9a90..2c046a2d28 100644 --- a/packages/stack/src/functions.unit.test.ts +++ b/packages/stack/src/functions.unit.test.ts @@ -1,16 +1,19 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; import { mkdtempSync } from "node:fs"; -import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { readFile, readdir, rm, stat } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { Effect } from "effect"; +import { Effect, Schema } from "effect"; import { resolveConfig } from "./StackConfigResolver.ts"; import { defaultJwtSecret, generateJwt } from "./JwtGenerator.ts"; import { + clearFunctionsRuntimeConfig, configureFunctionsRuntime, functionsRuntimeConfigPath, + ResolvedFunctionsBundleSchema, resolveFunctionsRuntimeConfig, + type ResolvedFunctionsBundle, } from "./functions.ts"; import { verifyRequest } from "./services/edge-runtime-main.ts"; @@ -18,42 +21,20 @@ function makeTempProject(): string { return mkdtempSync(join(tmpdir(), "supabase-stack-functions-")); } -async function writeProject(cwd: string) { - await mkdir(join(cwd, "supabase", "functions", "hello-world"), { recursive: true }); - await mkdir(join(cwd, "supabase", "functions", "disabled-function"), { recursive: true }); - await writeFile( - join(cwd, "supabase", "functions", "hello-world", "index.ts"), - "Deno.serve(() => Response.json({ ok: true }));\n", - ); - await writeFile( - join(cwd, "supabase", "functions", "disabled-function", "index.ts"), - "Deno.serve(() => Response.json({ disabled: true }));\n", - ); - await writeFile( - join(cwd, "supabase", ".env"), - "CONFIG_ONLY=from-project-env\nSHARED=from-project-env\n", - ); - await writeFile( - join(cwd, "supabase", "functions", ".env"), - "FILE_ONLY=from-functions-env\nSHARED=from-functions-env\n", - ); - await writeFile( - join(cwd, "supabase", "config.json"), - JSON.stringify({ - functions: { - "hello-world": { - verify_jwt: true, - env: { - CONFIG_ONLY: "env(CONFIG_ONLY)", - SHARED: "env(SHARED)", - }, - }, - "disabled-function": { - enabled: false, - }, +function makeBundle(root: string): ResolvedFunctionsBundle { + return { + env: { SHARED: "shared-value", BUNDLE_ONLY: "bundle-value" }, + functions: [ + { + name: "hello-world", + verifyJWT: true, + entrypointPath: join(root, "functions", "hello-world", "index.ts"), + importMapPath: null, + staticFiles: [join(root, "functions", "hello-world", "assets", "*")], + env: { SHARED: "function-value", FUNCTION_ONLY: "function-value" }, }, - }), - ); + ], + }; } function jwtWithInvalidSignature(algorithm?: string): string { @@ -101,95 +82,89 @@ const authFailureCases = [ ]; describe("stack Functions runtime config", () => { - it.live("auto-detects enabled functions from projectDir", () => { - const cwd = makeTempProject(); - - return Effect.gen(function* () { - yield* Effect.promise(() => writeProject(cwd)); - const stackConfig = yield* Effect.promise(() => resolveConfig({ projectDir: cwd })); - const config = yield* resolveFunctionsRuntimeConfig(stackConfig, { - hostname: "127.0.0.1", - }); - - expect(config).toBeDefined(); - expect(Object.keys(config!.functions)).toEqual(["hello-world"]); - expect(config!.functions["hello-world"]).toEqual({ - verifyJWT: true, - entrypointPath: join(cwd, "supabase", "functions", "hello-world", "index.ts"), - importMapPath: "", - staticFiles: [], - }); - expect(config!.env).toMatchObject({ - FILE_ONLY: "from-functions-env", - CONFIG_ONLY: "from-project-env", - SHARED: "from-project-env", - }); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.promise(() => rm(cwd, { recursive: true, force: true }))), + it("projects an explicit bundle without project discovery", async () => { + const root = makeTempProject(); + const stackConfig = await resolveConfig({ functions: makeBundle(root) }); + const config = resolveFunctionsRuntimeConfig( + stackConfig, + { hostname: "127.0.0.1" }, + makeBundle(root), ); - }); - - it.live("supports explicit env files and disabling JWT verification", () => { - const cwd = makeTempProject(); - return Effect.gen(function* () { - yield* Effect.promise(() => writeProject(cwd)); - yield* Effect.promise(() => writeFile(join(cwd, "custom.env"), "FILE_ONLY=custom\n")); - const stackConfig = yield* Effect.promise(() => - resolveConfig({ - projectDir: cwd, - functions: { - envFile: "custom.env", - noVerifyJwt: true, - }, - }), - ); - const config = yield* resolveFunctionsRuntimeConfig(stackConfig, { - hostname: "127.0.0.1", - }); + expect(config?.env).toEqual({ SHARED: "shared-value", BUNDLE_ONLY: "bundle-value" }); + expect(config?.functions["hello-world"]).toEqual({ + verifyJWT: true, + entrypointPath: join(root, "functions", "hello-world", "index.ts"), + importMapPath: null, + staticFiles: [join(root, "functions", "hello-world", "assets", "*")], + env: { SHARED: "function-value", FUNCTION_ONLY: "function-value" }, + }); - expect(config!.env.FILE_ONLY).toBe("custom"); - expect(config!.functions["hello-world"]?.verifyJWT).toBe(false); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.promise(() => rm(cwd, { recursive: true, force: true }))), - ); + await rm(root, { recursive: true, force: true }); }); - it.live("keeps placeholder mode when Functions are disabled", () => { - const cwd = makeTempProject(); + it("validates paths, import maps, and unique function names", async () => { + const decode = Schema.decodeUnknownSync(ResolvedFunctionsBundleSchema); + const root = makeTempProject(); + const bundle = makeBundle(root); - return Effect.gen(function* () { - yield* Effect.promise(() => writeProject(cwd)); - const stackConfig = yield* Effect.promise(() => - resolveConfig({ projectDir: cwd, functions: false }), - ); - const config = yield* resolveFunctionsRuntimeConfig(stackConfig, { - hostname: "127.0.0.1", - }); + expect(decode(bundle).functions[0]?.importMapPath).toBeNull(); + expect(() => + decode({ + ...bundle, + functions: [{ ...bundle.functions[0], entrypointPath: "./index.ts" }], + }), + ).toThrow("Expected an absolute path"); + expect(() => + decode({ + ...bundle, + functions: [bundle.functions[0], bundle.functions[0]], + }), + ).toThrow("Duplicate function name: hello-world"); - expect(config).toBeUndefined(); - }).pipe( - Effect.provide(BunServices.layer), - Effect.ensuring(Effect.promise(() => rm(cwd, { recursive: true, force: true }))), - ); + await rm(root, { recursive: true, force: true }); }); - it.live("writes generated runtime config into the stack runtime directory", () => { + it("keeps placeholder mode when no functions are supplied", async () => { + const stackConfig = await resolveConfig({ functions: false }); + + expect( + resolveFunctionsRuntimeConfig(stackConfig, { hostname: "127.0.0.1" }, undefined), + ).toBeUndefined(); + expect( + resolveFunctionsRuntimeConfig( + stackConfig, + { hostname: "127.0.0.1" }, + { + env: {}, + functions: [], + }, + ), + ).toBeUndefined(); + }); + + it.live("atomically writes restrictive ephemeral config and removes it", () => { const cwd = makeTempProject(); return Effect.gen(function* () { - yield* Effect.promise(() => writeProject(cwd)); - const stackConfig = yield* Effect.promise(() => resolveConfig({ projectDir: cwd })); - yield* configureFunctionsRuntime(stackConfig, { hostname: "127.0.0.1" }); - const written = JSON.parse( - yield* Effect.promise(() => - readFile(functionsRuntimeConfigPath(stackConfig.runtimeRoot), "utf8"), - ), - ) as { functions: Record }; + const bundle = makeBundle(cwd); + const stackConfig = yield* Effect.promise(() => + resolveConfig({ runtimeRoot: cwd, functions: bundle }), + ); + yield* configureFunctionsRuntime(stackConfig, { hostname: "127.0.0.1" }, bundle); + const filePath = functionsRuntimeConfigPath(stackConfig.runtimeRoot); + const written = JSON.parse(yield* Effect.promise(() => readFile(filePath, "utf8"))) as { + functions: Record; + }; expect(Object.keys(written.functions)).toEqual(["hello-world"]); + expect((yield* Effect.promise(() => stat(filePath))).mode & 0o777).toBe(0o600); + expect(yield* Effect.promise(() => readdir(join(cwd, "edge-runtime")))).toEqual([ + "functions-runtime-config.json", + ]); + + yield* clearFunctionsRuntimeConfig(stackConfig.runtimeRoot); + expect(yield* Effect.promise(() => readdir(join(cwd, "edge-runtime")))).toEqual([]); }).pipe( Effect.provide(BunServices.layer), Effect.ensuring(Effect.promise(() => rm(cwd, { recursive: true, force: true }))), diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index 13bdb9411f..bbba381b23 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -28,5 +28,10 @@ export type { ServiceName, VersionManifest } from "./versions.ts"; export type { ServiceResolution } from "./StackPreparation.ts"; export type { PrefetchOptions, PrefetchResult } from "./prefetch.ts"; export type { StackHandle } from "./createStack.ts"; -export type { FunctionsConfig, FunctionsRuntimeConfig } from "./functions.ts"; +export type { + FunctionsReloadConfig, + FunctionsRuntimeConfig, + ResolvedFunction, + ResolvedFunctionsBundle, +} from "./functions.ts"; export { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index cda66721c5..7bdd1cd266 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -177,6 +177,7 @@ async function serveFunction(req: Request, config: any, functionName: string, fu const envVars = Object.entries({ ...config.env, + ...functionConfig.env, SUPABASE_URL: config.supabaseUrl, SUPABASE_ANON_KEY: config.publishableKey, SUPABASE_SERVICE_ROLE_KEY: config.secretKey, @@ -192,7 +193,7 @@ async function serveFunction(req: Request, config: any, functionName: string, fu workerTimeoutMs: 400000, noModuleCache: false, noNpm: false, - importMapPath: functionConfig.importMapPath, + importMapPath: functionConfig.importMapPath ?? undefined, envVars, forceCreate: false, customModuleRoot: "", diff --git a/packages/stack/tests/createStack.e2e.test.ts b/packages/stack/tests/createStack.e2e.test.ts index 9b734fa450..6511c750ad 100644 --- a/packages/stack/tests/createStack.e2e.test.ts +++ b/packages/stack/tests/createStack.e2e.test.ts @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterAll, beforeAll, describe, expect, test } from "vitest"; -import { createStack, type StackHandle } from "../src/node.ts"; +import { createStack, type ResolvedFunctionsBundle, type StackHandle } from "../src/node.ts"; import { fetchFunctionWhenReady, setupTestTable } from "./helpers/e2e.ts"; const STACK_E2E_TEST_TIMEOUT_MS = 5_000; @@ -21,7 +21,7 @@ describe("createStack e2e", () => { stack = await createStack({ projectDir, - functions: { noVerifyJwt: true }, + functions: functionsBundle(projectDir, ["hello"]), jwtSecret: "super-secret-jwt-token-with-at-least-32-characters-long", postgres: { dataDir }, }); @@ -89,7 +89,7 @@ describe("createStack e2e", () => { test("reloadFunctions picks up newly added Edge Functions", { timeout: 30_000 }, async () => { writeFunction(projectDir, "later", "later"); - await stack.reloadFunctions({ noVerifyJwt: true }); + await stack.reloadFunctions({ functions: functionsBundle(projectDir, ["hello", "later"]) }); const res = await fetchFunctionWhenReady(`${stack.url}/functions/v1/later`); @@ -168,3 +168,20 @@ function writeFunction(projectDir: string, slug: string, body: string) { `Deno.serve(() => new Response(${JSON.stringify(body)}));\n`, ); } + +function functionsBundle( + projectDir: string, + names: ReadonlyArray, +): ResolvedFunctionsBundle { + return { + env: {}, + functions: names.map((name) => ({ + name, + verifyJWT: false, + entrypointPath: join(projectDir, "supabase", "functions", name, "index.ts"), + importMapPath: null, + staticFiles: [], + env: {}, + })), + }; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6e5837da1..0e20dd2890 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -517,9 +517,6 @@ importers: '@effect/platform-node': specifier: 'catalog:' version: 4.0.0-beta.97(effect@4.0.0-beta.97)(ioredis@5.11.1) - '@supabase/config': - specifier: workspace:* - version: link:../config '@supabase/process-compose': specifier: workspace:* version: link:../process-compose From 73bcbbbf3619281b5c5140afed588c6e63b53617 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 17:12:28 +0200 Subject: [PATCH 14/26] feat(cli): apply functions config during local start --- .../functions/dev/functions-dev-config.ts | 77 ++------- .../src/next/commands/start/start.command.ts | 19 ++- .../src/next/commands/start/start.handler.ts | 10 +- .../commands/start/start.integration.test.ts | 49 +++++- .../src/next/config/functions-stack-config.ts | 120 ++++++++++++++ .../functions-stack-config.unit.test.ts | 150 ++++++++++++++++++ .../next/config/local-stack-config-parity.ts | 12 +- .../config/stack-config.integration.test.ts | 40 ++++- apps/cli/src/next/config/stack-config.ts | 29 +++- .../src/next/config/stack-config.unit.test.ts | 26 +-- apps/cli/tests/helpers/mocks.ts | 12 +- packages/stack/src/functions.unit.test.ts | 25 ++- .../stack/src/services/edge-runtime-main.ts | 26 ++- 13 files changed, 490 insertions(+), 105 deletions(-) create mode 100644 apps/cli/src/next/config/functions-stack-config.ts create mode 100644 apps/cli/src/next/config/functions-stack-config.unit.test.ts 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/start/start.command.ts b/apps/cli/src/next/commands/start/start.command.ts index baf6900205..ac8a6bf857 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 { daemonEntryPoint } from "@supabase/stack"; @@ -69,6 +70,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."), @@ -201,6 +211,7 @@ export const startCommand = Command.make("start", flags).pipe( return { stackLayer, + startFunctionsState: StartFunctionsState.of({ bundle: launch.functionsBundle }), startVersionState: StartVersionState.of({ metadata, serviceVersionContext, @@ -210,8 +221,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.handler.ts b/apps/cli/src/next/commands/start/start.handler.ts index 6925658139..7ca68e408c 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.reloadFunctions({ 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 7258b7a983..5ad7066485 100644 --- a/apps/cli/src/next/commands/start/start.integration.test.ts +++ b/apps/cli/src/next/commands/start/start.integration.test.ts @@ -5,11 +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 { + 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 { @@ -141,6 +146,10 @@ function mockStartVersionState( ); } +function mockStartFunctionsState(bundle?: ResolvedFunctionsBundle) { + return Layer.succeed(StartFunctionsState, StartFunctionsState.of({ bundle })); +} + function setupInteractive( opts: { info?: Partial; @@ -163,6 +172,7 @@ function setupInteractive( analytics.layer, out.layer, ink.layer, + mockStartFunctionsState(), mockStartVersionState(), ); return { layer, stack, out, ink, analytics }; @@ -174,6 +184,7 @@ function setupNonInteractive( stateChanges?: Array<{ name: string; status: StackServiceStatus }>; startPending?: boolean; liveStateChanges?: boolean; + functionsBundle?: ResolvedFunctionsBundle; } = {}, ) { const stack = mockStack({ @@ -191,6 +202,7 @@ function setupNonInteractive( analytics.layer, out.layer, ink.layer, + mockStartFunctionsState(opts.functionsBundle), mockStartVersionState(), ); return { layer, stack, out, ink, analytics }; @@ -211,6 +223,36 @@ const waitFor = Effect.fnUntraced(function* ( }); describe("start", () => { + 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.functionsReloads).toEqual([{ functions: bundle }]); + expect(stack.operations.slice(0, 2)).toEqual(["reload-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* () { @@ -389,6 +431,7 @@ describe("start", () => { analytics.layer, out.layer, ink.layer, + mockStartFunctionsState(), mockStartVersionState({ metadata: stackMetadata({ ports: { @@ -517,6 +560,7 @@ describe("start", () => { analytics.layer, out.layer, ink.layer, + mockStartFunctionsState(), mockStartVersionState({ serviceVersionContext: { activeOverrides: [{ service: "storage", version: "1.40.0", source: "local" }], @@ -557,6 +601,7 @@ describe("start", () => { analytics.layer, out.layer, ink.layer, + mockStartFunctionsState(), mockStartVersionState({ serviceVersionContext: { activeOverrides: [{ service: "auth", version: "2.180.0", source: "flag" }], 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 index f00ac6a309..aa883c4dd1 100644 --- a/apps/cli/src/next/config/local-stack-config-parity.ts +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -141,9 +141,9 @@ const projectIdentityField: LocalStackConfigParityDecision = { const mappedFunctionManifest: LocalStackConfigParityDecision = { _tag: "mapped", presence: "raw-document", - mappedBy: "stack-functions-runtime", + mappedBy: "start", rationale: - "The current stack functions runtime resolves every configured function entry, including enablement, JWT verification, paths, static files, and environment values.", + "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 = { @@ -155,12 +155,12 @@ const functionConfigParity = { env: mappedFunctionManifest, } satisfies Record; -const mappedFunctionsDevEdgeRuntime: LocalStackConfigParityDecision = { +const mappedStartFunctionsEnvironment: LocalStackConfigParityDecision = { _tag: "mapped", presence: "raw-document", - mappedBy: "functions-dev", + mappedBy: "start", rationale: - "The functions-dev Adapter resolves this field and passes it to the stack edge-runtime configuration.", + "The start launch translator resolves Edge Runtime secrets into the shared Functions environment before the bundle crosses the daemon reload transport.", }; const commandOnlyDatabaseField: LocalStackConfigParityDecision = { @@ -495,7 +495,7 @@ const localStackConfigParity = { policy: mappedCoreTopologyField, inspector_port: mappedCoreTopologyField, deno_version: unsupportedRuntimeField, - secrets: mappedFunctionsDevEdgeRuntime, + secrets: mappedStartFunctionsEnvironment, } satisfies Record, functions: { "*": functionConfigParity, diff --git a/apps/cli/src/next/config/stack-config.integration.test.ts b/apps/cli/src/next/config/stack-config.integration.test.ts index 49fa635665..26632d885e 100644 --- a/apps/cli/src/next/config/stack-config.integration.test.ts +++ b/apps/cli/src/next/config/stack-config.integration.test.ts @@ -1,4 +1,5 @@ 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"; @@ -6,6 +7,9 @@ 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-")); @@ -37,7 +41,7 @@ describe("local stack launch config", () => { projectEnv: projectEnvironment, }); const result = await Effect.runPromise( - resolveLocalStackLaunch({ + resolveLocalStackLaunchWithBun({ loadedProjectConfig, projectEnvironment, projectPaths: { projectRoot, projectStateRoot: join(projectRoot, ".supabase") }, @@ -125,7 +129,7 @@ describe("local stack launch config", () => { projectEnv: projectEnvironment, }); const result = await Effect.runPromise( - resolveLocalStackLaunch({ + resolveLocalStackLaunchWithBun({ loadedProjectConfig, projectEnvironment, projectPaths: { projectRoot, projectStateRoot: join(projectRoot, ".supabase") }, @@ -193,7 +197,7 @@ describe("local stack launch config", () => { projectEnv: projectEnvironment, }); const result = await Effect.runPromise( - resolveLocalStackLaunch({ + resolveLocalStackLaunchWithBun({ loadedProjectConfig, projectEnvironment, projectPaths: { projectRoot, projectStateRoot: join(projectRoot, ".supabase") }, @@ -278,7 +282,7 @@ describe("local stack launch config", () => { projectEnv: projectEnvironment, }); const result = await Effect.runPromise( - resolveLocalStackLaunch({ + resolveLocalStackLaunchWithBun({ loadedProjectConfig, projectEnvironment, projectPaths: { projectRoot, projectStateRoot: join(projectRoot, ".supabase") }, @@ -322,4 +326,32 @@ describe("local stack launch config", () => { 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 d27cf88f26..2bbed0b869 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -4,11 +4,17 @@ import { type ProjectConfig, type ProjectEnvironment, } from "@supabase/config"; -import type { ReadinessPolicy, StackConfig, VersionManifest } from "@supabase/stack/effect"; +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, @@ -58,6 +64,7 @@ export interface LocalStackWarning { interface ResolvedLocalStackLaunch { readonly stackConfig: StackConfig; + readonly functionsBundle: ResolvedFunctionsBundle | undefined; readonly projectPaths: LocalStackProjectPaths; readonly warnings: ReadonlyArray; } @@ -341,16 +348,27 @@ export const resolveLocalStackLaunch = Effect.fnUntraced(function* (input: Local paths: [], }), }); + const configDir = + input.loadedProjectConfig === null + ? join(input.projectPaths.projectRoot, "supabase") + : dirname(input.loadedProjectConfig.path); const translatedAuth = yield* translateAuthStackConfig({ projectConfig, rawDocument: input.loadedProjectConfig?.document, projectEnvironment: input.projectEnvironment, - configDir: - input.loadedProjectConfig === null - ? join(input.projectPaths.projectRoot, "supabase") - : dirname(input.loadedProjectConfig.path), + 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, @@ -410,6 +428,7 @@ export const resolveLocalStackLaunch = Effect.fnUntraced(function* (input: Local startupHealthTimeoutMs: postgresStartupTimeoutMs, }, }, + functionsBundle, projectPaths: input.projectPaths, warnings: [...diagnostics.warnings, ...databaseWarnings, ...deprecationWarnings], } satisfies ResolvedLocalStackLaunch; 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 5d14ef7774..09849db525 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -1,4 +1,5 @@ import { ProjectConfigSchema, type LoadedProjectConfig } from "@supabase/config"; +import { BunServices } from "@effect/platform-bun"; import { Effect, Schema } from "effect"; import { describe, expect, it } from "vitest"; import { @@ -12,6 +13,9 @@ import { const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); +const resolveLocalStackLaunchWithBun = (input: Parameters[0]) => + resolveLocalStackLaunch(input).pipe(Effect.provide(BunServices.layer)); + function loaded(document: Record): LoadedProjectConfig { return { path: "/project/supabase/config.toml", @@ -112,7 +116,7 @@ describe("explicitLocalStackConfigEntries", () => { describe("resolveLocalStackLaunch", () => { it("maps API and database topology into the stack interface", async () => { const result = await Effect.runPromise( - resolveLocalStackLaunch({ + resolveLocalStackLaunchWithBun({ ...baseLaunchInput, loadedProjectConfig: loaded({ api: { @@ -140,7 +144,7 @@ describe("resolveLocalStackLaunch", () => { it("applies environment overrides before CLI exclusions", async () => { const result = await Effect.runPromise( - resolveLocalStackLaunch({ + resolveLocalStackLaunchWithBun({ ...baseLaunchInput, loadedProjectConfig: loaded({ api: { enabled: false, port: 6101 }, db: { port: 6102 } }), projectEnvironment: { @@ -170,7 +174,7 @@ describe("resolveLocalStackLaunch", () => { it("reports malformed topology overrides by path without retaining their value", async () => { const exit = await Effect.runPromise( - resolveLocalStackLaunch({ + resolveLocalStackLaunchWithBun({ ...baseLaunchInput, projectEnvironment: { paths: { @@ -194,13 +198,13 @@ describe("resolveLocalStackLaunch", () => { it("only requests Mailpit protocol publication for explicit host ports", async () => { const omitted = await Effect.runPromise( - resolveLocalStackLaunch({ + resolveLocalStackLaunchWithBun({ ...baseLaunchInput, loadedProjectConfig: loaded({ local_smtp: { enabled: true, port: 6104 } }), }), ); const explicit = await Effect.runPromise( - resolveLocalStackLaunch({ + resolveLocalStackLaunchWithBun({ ...baseLaunchInput, loadedProjectConfig: loaded({ local_smtp: { enabled: true, port: 6104, smtp_port: 6105, pop3_port: 6106 }, @@ -218,7 +222,7 @@ describe("resolveLocalStackLaunch", () => { it("composes project config, paths, flags, versions, and finite readiness", async () => { const result = await Effect.runPromise( - resolveLocalStackLaunch({ + resolveLocalStackLaunchWithBun({ ...baseLaunchInput, loadedProjectConfig: loaded({ api: { auto_expose_new_tables: true }, @@ -249,7 +253,7 @@ describe("resolveLocalStackLaunch", () => { it("uses the resolved project environment for the database health timeout", async () => { const result = await Effect.runPromise( - resolveLocalStackLaunch({ + resolveLocalStackLaunchWithBun({ ...baseLaunchInput, projectEnvironment: { paths: { @@ -272,7 +276,7 @@ describe("resolveLocalStackLaunch", () => { it("supports an explicit infinite debugging policy while retaining startup health", async () => { const result = await Effect.runPromise( - resolveLocalStackLaunch({ ...baseLaunchInput, readiness: "infinite" }), + resolveLocalStackLaunchWithBun({ ...baseLaunchInput, readiness: "infinite" }), ); expect(result.stackConfig.postgres?.startupHealthTimeoutMs).toBe(120_000); @@ -281,7 +285,7 @@ describe("resolveLocalStackLaunch", () => { it("fails before stack construction when the health timeout is invalid", async () => { const exit = await Effect.runPromise( - resolveLocalStackLaunch({ + resolveLocalStackLaunchWithBun({ ...baseLaunchInput, loadedProjectConfig: loaded({ db: { health_timeout: "-1s" } }), }).pipe(Effect.exit), @@ -292,7 +296,7 @@ describe("resolveLocalStackLaunch", () => { it("fails on explicit blocking fields and reports paths without values", async () => { const exit = await Effect.runPromise( - resolveLocalStackLaunch({ + resolveLocalStackLaunchWithBun({ ...baseLaunchInput, loadedProjectConfig: loaded({ auth: { captcha: { secret: "do-not-leak" } }, @@ -315,7 +319,7 @@ describe("resolveLocalStackLaunch", () => { it("warns on explicit warning fields using paths only", async () => { const result = await Effect.runPromise( - resolveLocalStackLaunch({ + resolveLocalStackLaunchWithBun({ ...baseLaunchInput, loadedProjectConfig: loaded({ experimental: { s3_secret_key: "do-not-leak" }, diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index 885009f822..b5f3f2ccc2 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,8 @@ export function mockStack( ) { let started = false; let stopped = false; + const functionsReloads: FunctionsReloadConfig[] = []; + const operations: string[] = []; const startDeferred = Deferred.makeUnsafe(); const stopDeferred = Deferred.makeUnsafe(); const stateHistory = [...(opts.stateChanges ?? [])]; @@ -653,6 +656,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 +681,11 @@ export function mockStack( startService: () => Effect.void, stopService: () => Effect.void, restartService: () => Effect.void, - reloadFunctions: () => Effect.void, + reloadFunctions: (config) => + Effect.sync(() => { + functionsReloads.push(config ?? {}); + operations.push("reload-functions"); + }), reloadEdgeRuntime: () => Effect.void, getState: () => Effect.succeed( @@ -746,6 +754,8 @@ export function mockStack( get stopped() { return stopped; }, + functionsReloads, + operations, emitStateChange(change: { name: string; status: StackServiceState["status"] }) { stateHistory.push(change); PubSub.publishUnsafe( diff --git a/packages/stack/src/functions.unit.test.ts b/packages/stack/src/functions.unit.test.ts index 2c046a2d28..11def36a47 100644 --- a/packages/stack/src/functions.unit.test.ts +++ b/packages/stack/src/functions.unit.test.ts @@ -15,7 +15,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-")); @@ -82,6 +82,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) }); diff --git a/packages/stack/src/services/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index 7bdd1cd266..b0efe41ad7 100644 --- a/packages/stack/src/services/edge-runtime-main.ts +++ b/packages/stack/src/services/edge-runtime-main.ts @@ -171,13 +171,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 +194,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({ From 79c29682552c3c4bf6744a4beb0151248df2fe7a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 17:25:16 +0200 Subject: [PATCH 15/26] refactor(stack): narrow package entrypoints --- .../branches/switch/switch.handler.ts | 20 ++- .../commands/functions/dev/dev.command.ts | 3 +- .../functions/dev/functions-dev-runtime.ts | 22 ++- .../functions/list/list.integration.test.ts | 2 +- .../functions/new/new.integration.test.ts | 2 +- .../commands/logs/logs.integration.test.ts | 2 +- .../src/next/commands/start/start.command.ts | 18 +-- .../status/status.integration.test.ts | 2 +- .../commands/stop/stop.integration.test.ts | 2 +- apps/cli/src/shared/cli/run.ts | 2 +- apps/cli/tests/helpers/mocks.ts | 2 +- apps/cli/tests/helpers/running-stack.ts | 4 +- packages/stack/docs/architecture.md | 23 +-- packages/stack/docs/detach-mode.md | 29 ++-- packages/stack/package.json | 6 +- packages/stack/src/JwtGenerator.ts | 17 +-- .../src/UnixSocketSse.integration.test.ts | 2 +- packages/stack/src/bun.ts | 46 +----- packages/stack/src/createStack.ts | 38 +---- packages/stack/src/daemon-node.ts | 7 +- packages/stack/src/daemon.ts | 4 +- packages/stack/src/effect-bun.ts | 20 +++ packages/stack/src/effect-node.ts | 20 +++ packages/stack/src/effect.ts | 32 +---- packages/stack/src/entrypoints.unit.test.ts | 64 +++++++-- packages/stack/src/index.ts | 1 - packages/stack/src/node.ts | 132 +----------------- packages/stack/src/platform-bun.ts | 30 ++++ packages/stack/src/platform-node.ts | 109 +++++++++++++++ packages/stack/src/testing.ts | 3 + 30 files changed, 328 insertions(+), 336 deletions(-) create mode 100644 packages/stack/src/effect-bun.ts create mode 100644 packages/stack/src/effect-node.ts create mode 100644 packages/stack/src/platform-bun.ts create mode 100644 packages/stack/src/platform-node.ts create mode 100644 packages/stack/src/testing.ts 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 7e35d08d3a..ce529b3235 100644 --- a/apps/cli/src/next/commands/branches/switch/switch.handler.ts +++ b/apps/cli/src/next/commands/branches/switch/switch.handler.ts @@ -1,5 +1,4 @@ import { StateManager, daemonLayer, resolveManagedStack, stopDaemon } from "@supabase/stack/effect"; -import { daemonEntryPoint } from "@supabase/stack"; import { Effect, Option } from "effect"; import { PlatformApi } from "../../../auth/platform-api.service.ts"; import { CliConfig } from "../../../config/cli-config.service.ts"; @@ -154,17 +153,14 @@ export const switchBranch = Effect.fn("branches.switch")(function* (opts: { }, }); - const stackLayer = yield* daemonLayer( - { - cacheRoot: cliConfig.supabaseHome, - cwd: runtimeInfo.cwd, - projectDir: projectHome.projectRoot, - projectStateRoot: projectHome.projectHomeDir, - name: stackState.name, - ...launchConfig, - }, - daemonEntryPoint, - ); + const stackLayer = yield* daemonLayer({ + cacheRoot: cliConfig.supabaseHome, + cwd: runtimeInfo.cwd, + projectDir: projectHome.projectRoot, + projectStateRoot: projectHome.projectHomeDir, + name: stackState.name, + ...launchConfig, + }); yield* Effect.scoped( Effect.gen(function* () { diff --git a/apps/cli/src/next/commands/functions/dev/dev.command.ts b/apps/cli/src/next/commands/functions/dev/dev.command.ts index 3e84cba86e..04d932246b 100644 --- a/apps/cli/src/next/commands/functions/dev/dev.command.ts +++ b/apps/cli/src/next/commands/functions/dev/dev.command.ts @@ -1,5 +1,4 @@ -import { unixHttpClientLayer } from "@supabase/stack"; -import { DEFAULT_MANAGED_STACK_NAME } from "@supabase/stack/effect"; +import { DEFAULT_MANAGED_STACK_NAME, unixHttpClientLayer } from "@supabase/stack/effect"; import { Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; 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 8922ec520a..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 @@ -1,4 +1,3 @@ -import { daemonEntryPoint } from "@supabase/stack"; import { connectLayer, daemonLayer, @@ -68,18 +67,15 @@ const startFullStack = Effect.fnUntraced(function* (opts: FunctionsDevStackOptio yield* ensureProjectStateIgnored(projectHome.projectRoot); const serviceVersionContext = yield* resolveServiceVersionContext([], undefined); - const stackLayer = yield* daemonLayer( - { - cacheRoot: cliConfig.supabaseHome, - cwd: runtimeInfo.cwd, - projectDir: projectHome.projectRoot, - projectStateRoot: projectHome.projectHomeDir, - name: opts.stack, - edgeRuntime: opts.edgeRuntime, - ...versionsFromContext(serviceVersionContext), - }, - daemonEntryPoint, - ); + const stackLayer = yield* daemonLayer({ + cacheRoot: cliConfig.supabaseHome, + cwd: runtimeInfo.cwd, + projectDir: projectHome.projectRoot, + projectStateRoot: projectHome.projectHomeDir, + name: opts.stack, + edgeRuntime: opts.edgeRuntime, + ...versionsFromContext(serviceVersionContext), + }); const state = yield* stateManager.read(opts.stack); yield* stateManager.writeMetadata( diff --git a/apps/cli/src/next/commands/functions/list/list.integration.test.ts b/apps/cli/src/next/commands/functions/list/list.integration.test.ts index 68ee7c2c3d..c7e5e99e41 100644 --- a/apps/cli/src/next/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/next/commands/functions/list/list.integration.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from "@effect/vitest"; import { FunctionResponse } from "@supabase/api/effect"; import { BunServices } from "@effect/platform-bun"; -import { unixHttpClientLayer } from "@supabase/stack"; +import { unixHttpClientLayer } from "@supabase/stack/effect"; import { mkdtempSync } from "node:fs"; import { mkdir, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; diff --git a/apps/cli/src/next/commands/functions/new/new.integration.test.ts b/apps/cli/src/next/commands/functions/new/new.integration.test.ts index 7396813785..a545d8104b 100644 --- a/apps/cli/src/next/commands/functions/new/new.integration.test.ts +++ b/apps/cli/src/next/commands/functions/new/new.integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { unixHttpClientLayer } from "@supabase/stack"; +import { unixHttpClientLayer } from "@supabase/stack/effect"; import { existsSync, mkdtempSync } from "node:fs"; import { mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; diff --git a/apps/cli/src/next/commands/logs/logs.integration.test.ts b/apps/cli/src/next/commands/logs/logs.integration.test.ts index f9e554e959..95c1eb6e36 100644 --- a/apps/cli/src/next/commands/logs/logs.integration.test.ts +++ b/apps/cli/src/next/commands/logs/logs.integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { unixHttpClientLayer } from "@supabase/stack"; +import { unixHttpClientLayer } from "@supabase/stack/effect"; import { Effect, Exit, Fiber, Layer } from "effect"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; diff --git a/apps/cli/src/next/commands/start/start.command.ts b/apps/cli/src/next/commands/start/start.command.ts index ac8a6bf857..e210484c1d 100644 --- a/apps/cli/src/next/commands/start/start.command.ts +++ b/apps/cli/src/next/commands/start/start.command.ts @@ -8,7 +8,6 @@ import { type ResolvedFunctionsBundle, type StackMetadata, } from "@supabase/stack/effect"; -import { daemonEntryPoint } from "@supabase/stack"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; import { projectLocalServiceVersionsLayer } from "../../config/project-local-service-versions.layer.ts"; @@ -183,16 +182,13 @@ export const startCommand = Command.make("start", flags).pipe( yield* output.intro("Start local Supabase stack"); yield* ensureProjectStateIgnored(projectHome.projectRoot); - const stackLayer = yield* daemonLayer( - { - cacheRoot: cliConfig.supabaseHome, - cwd: runtimeInfo.cwd, - projectStateRoot: projectHome.projectHomeDir, - name: flags.stack, - ...launch.stackConfig, - }, - daemonEntryPoint, - ); + const stackLayer = yield* daemonLayer({ + cacheRoot: cliConfig.supabaseHome, + cwd: runtimeInfo.cwd, + projectStateRoot: projectHome.projectHomeDir, + name: flags.stack, + ...launch.stackConfig, + }); const daemonState = yield* stateManager.read(flags.stack); const metadata = stackMetadata({ diff --git a/apps/cli/src/next/commands/status/status.integration.test.ts b/apps/cli/src/next/commands/status/status.integration.test.ts index 95ac5bc27a..f12be0f0ee 100644 --- a/apps/cli/src/next/commands/status/status.integration.test.ts +++ b/apps/cli/src/next/commands/status/status.integration.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "@effect/vitest"; import { BunServices } from "@effect/platform-bun"; -import { unixHttpClientLayer } from "@supabase/stack"; +import { unixHttpClientLayer } from "@supabase/stack/effect"; import { StackServiceState } from "@supabase/stack/effect"; import { Effect, Layer } from "effect"; import { status } from "./status.handler.ts"; diff --git a/apps/cli/src/next/commands/stop/stop.integration.test.ts b/apps/cli/src/next/commands/stop/stop.integration.test.ts index bcf7a8d46f..1f8167e436 100644 --- a/apps/cli/src/next/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/next/commands/stop/stop.integration.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { Effect, Exit, Layer } from "effect"; import { BunServices } from "@effect/platform-bun"; -import { unixHttpClientLayer } from "@supabase/stack"; +import { unixHttpClientLayer } from "@supabase/stack/effect"; import { stop } from "./stop.handler.ts"; import { mockOutput, withEnv } from "../../../../tests/helpers/mocks.ts"; import { diff --git a/apps/cli/src/shared/cli/run.ts b/apps/cli/src/shared/cli/run.ts index 0ee530f2d3..0175631686 100644 --- a/apps/cli/src/shared/cli/run.ts +++ b/apps/cli/src/shared/cli/run.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { ProjectConfigStore } from "@supabase/config"; -import { unixHttpClientLayer } from "@supabase/stack"; +import { unixHttpClientLayer } from "@supabase/stack/effect"; import { Cause, Console, Effect, Exit, Fiber, Layer, Runtime, Stdio } from "effect"; import { CliError, CliOutput, Command } from "effect/unstable/cli"; import { CLI_VERSION } from "./version.ts"; diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index b5f3f2ccc2..97dc13600c 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -15,7 +15,7 @@ import { type StackMetadata, type StackState, } from "@supabase/stack/effect"; -import { UnixHttpClient } from "@supabase/stack"; +import { UnixHttpClient } from "@supabase/stack/testing"; import { Api } from "../../src/next/auth/api.service.ts"; import type { LoginSessionResponse, ProfileResponse } from "../../src/next/auth/api.service.ts"; import { Credentials } from "../../src/next/auth/credentials.service.ts"; diff --git a/apps/cli/tests/helpers/running-stack.ts b/apps/cli/tests/helpers/running-stack.ts index 50283f76fa..9e7ed2e106 100644 --- a/apps/cli/tests/helpers/running-stack.ts +++ b/apps/cli/tests/helpers/running-stack.ts @@ -1,8 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; -import { unixHttpClientLayer } from "@supabase/stack"; import { - DaemonServer, DEFAULT_VERSIONS, fullVersionManifest, type PartialVersionManifest, @@ -14,7 +12,9 @@ import { type StackInfo, type StackMetadata, type StackState, + unixHttpClientLayer, } from "@supabase/stack/effect"; +import { DaemonServer } from "@supabase/stack/testing"; import { Effect, Layer, ManagedRuntime, Option, Stream } from "effect"; import { spawn, type ChildProcess } from "node:child_process"; import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; diff --git a/packages/stack/docs/architecture.md b/packages/stack/docs/architecture.md index ffb1db8f5c..65ae8bcf33 100644 --- a/packages/stack/docs/architecture.md +++ b/packages/stack/docs/architecture.md @@ -11,12 +11,14 @@ The package exposes two levels of Interface: - `@supabase/stack` selects `bun.ts` or `node.ts` through export conditions and exposes the Promise-oriented `createStack()` / `StackHandle` Interface plus prefetch helpers. -- `@supabase/stack/effect` exposes Effect Interfaces and layer factories used by the CLI and - advanced callers. +- `@supabase/stack/effect` selects a runtime Adapter through the same export conditions and exposes + Effect Interfaces plus platform-bound layer factories used by the CLI and advanced callers. +- `@supabase/stack/testing` exposes only the service tags needed to replace daemon transport in + consumer tests. Runtime implementation tags do not leak through the root or Effect barrels. -The root runtime Adapters provide Effect filesystem, path, child-process, HTTP-server, and Unix -socket HTTP implementations. `createStack.ts` remains platform-agnostic and receives a -`PlatformFactory`. +Internal runtime Adapters provide Effect filesystem, path, child-process, HTTP-server, and Unix +socket HTTP implementations. `createStack.ts` and the layer factories remain platform-agnostic; +the conditional root and Effect entries bind them to their selected runtime. ```mermaid flowchart LR @@ -316,12 +318,15 @@ Callers may explicitly supply `projectStateRoot`, in which case durable stacks l ## Runtime entrypoints and exports - `bun.ts` and `node.ts` are root export-condition targets. +- `effect-bun.ts` and `effect-node.ts` are Effect export-condition targets. They bind foreground, + daemon, and Unix-socket layers without exposing raw platform factories or bootstrap paths. - `daemon-bun.ts` is exported as `@supabase/stack/daemon-bun` so the compiled CLI can dispatch to it in-process. -- `daemon-node.ts` is intentionally not a package export. `node.ts` resolves it by file URL and - passes that filesystem path to `daemonLayer`; the package `knip.entry` list preserves this live - file-URL-only entrypoint. -- `effect.ts` is the low-level Effect export used by the CLI. There is no `internals.ts` entrypoint. +- `daemon-node.ts` is intentionally not a package export. The internal Node platform Adapter + resolves it by file URL and passes that filesystem path to `daemonLayer`; the package + `knip.entry` list preserves this live file-URL-only entrypoint. +- `effect.ts` is the platform-agnostic consumer contract re-exported by the conditional Effect + entries. There is no general-purpose `internals.ts` entrypoint. ## Testing diff --git a/packages/stack/docs/detach-mode.md b/packages/stack/docs/detach-mode.md index f1a1b6ecbd..14f715631b 100644 --- a/packages/stack/docs/detach-mode.md +++ b/packages/stack/docs/detach-mode.md @@ -159,20 +159,21 @@ crash-recovery metadata is deliberately separate from user-facing `/status` conn ## Package entrypoints -| File | Reachability and role | -| --------------------- | ------------------------------------------------------------------------------------------------------------- | -| `src/daemon.ts` | Shared daemon protocol and lifecycle; receives runtime-specific HTTP-server factories. | -| `src/daemon-bun.ts` | Bun daemon Adapter. Exported as `@supabase/stack/daemon-bun` for compiled CLI dispatch. | -| `src/daemon-node.ts` | Node daemon Adapter. Intentionally file-URL-only: `node.ts` resolves its path and passes it to `daemonLayer`. | -| `src/DaemonServer.ts` | Unix-socket HTTP/SSE Adapter over `Stack`. | -| `src/RemoteStack.ts` | Remote Effect `Stack` Adapter over that transport. | -| `src/layers.ts` | Foreground, foreground-daemon, forked-daemon, and connect layer composition. | -| `src/StateManager.ts` | Durable metadata, live-state claims, scanning, stale-state removal, and deletion. | -| `src/effect.ts` | Effect-facing exports consumed by the CLI and advanced callers. | - -There is no `internals.ts`. `daemon-node.ts` is not a package export because Node root consumers -reach it by the file URL returned from `node.ts`; it is listed under `knip.entry` in `package.json` -so static unused-code analysis preserves that live entrypoint. +| File | Reachability and role | +| --------------------- | ------------------------------------------------------------------------------------------------------- | +| `src/daemon.ts` | Shared daemon protocol and lifecycle; receives runtime-specific HTTP-server factories. | +| `src/daemon-bun.ts` | Bun daemon Adapter. Exported as `@supabase/stack/daemon-bun` for compiled CLI dispatch. | +| `src/daemon-node.ts` | Node daemon Adapter. Intentionally file-URL-only: the internal Node platform Adapter resolves its path. | +| `src/DaemonServer.ts` | Unix-socket HTTP/SSE Adapter over `Stack`. | +| `src/RemoteStack.ts` | Remote Effect `Stack` Adapter over that transport. | +| `src/layers.ts` | Foreground, foreground-daemon, forked-daemon, and connect layer composition. | +| `src/StateManager.ts` | Durable metadata, live-state claims, scanning, stale-state removal, and deletion. | +| `src/effect-*.ts` | Conditional Effect entries that bind consumer layers to Bun or Node. | +| `src/effect.ts` | Platform-agnostic Effect contracts re-exported by the conditional entries. | + +There is no `internals.ts`. `daemon-node.ts` is not a package export because the Node Effect +Adapter reaches it through the file URL returned by the internal platform module; it is listed +under `knip.entry` in `package.json` so static unused-code analysis preserves that live entrypoint. ## Compiled executable re-entry diff --git a/packages/stack/package.json b/packages/stack/package.json index 80161f67b9..a25e82d9bf 100644 --- a/packages/stack/package.json +++ b/packages/stack/package.json @@ -8,7 +8,11 @@ "bun": "./src/bun.ts", "default": "./src/node.ts" }, - "./effect": "./src/effect.ts", + "./effect": { + "bun": "./src/effect-bun.ts", + "default": "./src/effect-node.ts" + }, + "./testing": "./src/testing.ts", "./daemon-bun": "./src/daemon-bun.ts" }, "scripts": { diff --git a/packages/stack/src/JwtGenerator.ts b/packages/stack/src/JwtGenerator.ts index 83ac2115d6..ae4566cfd3 100644 --- a/packages/stack/src/JwtGenerator.ts +++ b/packages/stack/src/JwtGenerator.ts @@ -1,5 +1,4 @@ import { createHmac } from "node:crypto"; -import { Effect, Layer, Context } from "effect"; // Hardcoded opaque key defaults matching Go CLI (pkg/config/apikeys.go:19-20). // These are client-facing keys for local dev — SDKs use these, not JWTs directly. @@ -10,8 +9,7 @@ export const defaultSecretKey = "sb_secret_N7UND0UgjKTVK-Uodkm0Hg_xSvEMPvz"; export const defaultJwtSecret = "super-secret-jwt-token-with-at-least-32-characters-long"; /** - * Pure synchronous JWT generation. Used both by the JwtGenerator service - * and directly in createStack() where JWTs are needed before layers run. + * Pure synchronous JWT generation used while resolving stack configuration. */ export function generateJwt(secret: string, role: string): string { const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" })).toString("base64url"); @@ -38,16 +36,3 @@ export function generateJwks(secret: string): string { ], }); } - -export class JwtGenerator extends Context.Service< - JwtGenerator, - { - readonly generate: (secret: string, role: string) => Effect.Effect; - readonly generateJwks: (secret: string) => Effect.Effect; - } ->()("local/JwtGenerator") { - static layer: Layer.Layer = Layer.succeed(this, { - generate: (secret: string, role: string) => Effect.sync(() => generateJwt(secret, role)), - generateJwks: (secret: string) => Effect.sync(() => generateJwks(secret)), - }); -} diff --git a/packages/stack/src/UnixSocketSse.integration.test.ts b/packages/stack/src/UnixSocketSse.integration.test.ts index e6694814da..c7de5133b0 100644 --- a/packages/stack/src/UnixSocketSse.integration.test.ts +++ b/packages/stack/src/UnixSocketSse.integration.test.ts @@ -10,7 +10,7 @@ import { DaemonServer } from "./DaemonServer.ts"; import { RemoteStack } from "./RemoteStack.ts"; import { Stack, type StackInfo } from "./Stack.ts"; import { StackServiceState } from "./StackServiceState.ts"; -import { unixHttpClientLayer } from "./bun.ts"; +import { unixHttpClientLayer } from "./platform-bun.ts"; const REFERENCE_IDLE_TIMEOUT_SECONDS = 1; // Keep the idle gap just past a short reference timeout so the suite stays fast. diff --git a/packages/stack/src/bun.ts b/packages/stack/src/bun.ts index ebc92eb69c..2d05640881 100644 --- a/packages/stack/src/bun.ts +++ b/packages/stack/src/bun.ts @@ -1,59 +1,17 @@ import { BunServices } from "@effect/platform-bun"; -import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; -import { fileURLToPath } from "node:url"; import { Effect, Layer } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { BinaryResolver } from "./BinaryResolver.ts"; -import { - createStack as createStackCore, - type PlatformFactory, - type StackHandle, -} from "./createStack.ts"; +import { createStack as createStackCore, type StackHandle } from "./createStack.ts"; import { prefetch as prefetchEffect, type PrefetchOptions, type PrefetchResult, } from "./prefetch.ts"; import { defaultCacheRoot } from "./paths.ts"; +import { platformFactory } from "./platform-bun.ts"; import { StackPreparation } from "./StackPreparation.ts"; import type { StackConfig } from "./StackConfig.ts"; -import { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; - -interface BunUnixRequestInit extends RequestInit { - readonly unix: string; -} - -export const unixHttpClientLayer = Layer.succeed(UnixHttpClient, { - request: (socketPath, path, init) => - Effect.tryPromise({ - try: () => { - const requestInit: BunUnixRequestInit = { - ...init, - unix: socketPath, - }; - return fetch(`http://localhost${path}`, requestInit); - }, - catch: (cause) => new UnixHttpClientError({ socketPath, path, cause }), - }), -}); - -// --------------------------------------------------------------------------- -// Platform values — for use with Effect layer factories -// --------------------------------------------------------------------------- - -/** Bun platform factory for use with foregroundLayer / daemonLayer. */ -export const platformFactory: PlatformFactory = ({ apiPort, releaseApiPort }) => - Layer.mergeAll( - BunServices.layer, - Layer.unwrap(releaseApiPort.pipe(Effect.as(BunHttpServer.layer({ port: apiPort })))), - ); - -/** Path to the Bun daemon entry point for use with daemonLayer. */ -export const daemonEntryPoint: string = fileURLToPath(new URL("./daemon-bun.ts", import.meta.url)); - -// --------------------------------------------------------------------------- -// Promise API — convenience wrappers for non-Effect consumers -// --------------------------------------------------------------------------- export async function createStack(config?: StackConfig): Promise { return createStackCore(config, platformFactory); diff --git a/packages/stack/src/createStack.ts b/packages/stack/src/createStack.ts index fc89fb2edb..4864d0223b 100644 --- a/packages/stack/src/createStack.ts +++ b/packages/stack/src/createStack.ts @@ -1,12 +1,11 @@ import type { LogEntry } from "@supabase/process-compose"; -import { Effect, type Layer, ManagedRuntime, Stream } from "effect"; -import { FileSystem, Path } from "effect"; +import { Effect, FileSystem, type Layer, ManagedRuntime, Path, Stream } from "effect"; 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 { daemonLayer, foregroundLayer, type DaemonStartError } from "./layers.ts"; +import { foregroundLayer } from "./layers.ts"; import { PORT_FIELDS, reservePorts, type PortLease } from "./PortAllocator.ts"; import { allocatedPortFieldsForConfig } from "./ServicePorts.ts"; import { Stack } from "./Stack.ts"; @@ -14,18 +13,16 @@ import type { EdgeRuntimeReloadConfig } from "./Stack.ts"; import type { ReadyOptions, ResolvedStackConfig, StackConfig } from "./StackConfig.ts"; import { resolveConfig } from "./StackConfigResolver.ts"; import type { StackServiceState } from "./StackServiceState.ts"; -import { InvalidStackStateError, StackAlreadyRunningError } from "./StateManager.ts"; -import { UnixHttpClient } from "./UnixHttpClient.ts"; -export type PlatformServices = +type PlatformServices = | FileSystem.FileSystem | Path.Path | ChildProcessSpawner.ChildProcessSpawner | HttpServer.HttpServer; -export type PlatformLayer = Layer.Layer; +type PlatformLayer = Layer.Layer; /** Supplies the platform HTTP server used by the stack and HTTP proxy. */ -export interface PlatformFactoryOptions { +interface PlatformFactoryOptions { readonly apiPort: number; readonly releaseApiPort: Effect.Effect; } @@ -55,31 +52,6 @@ export interface StackHandle extends AsyncDisposable { logHistory(name: string, limit?: number): Promise>; } -export const projectDaemonLayer = (opts: { - readonly cacheRoot: string; - readonly cwd: string; - readonly projectDir?: string; - readonly projectStateRoot?: string; - readonly name?: string; - readonly daemonEntryPoint: string; - readonly stackConfig?: Omit; -}): Effect.Effect< - Layer.Layer, - DaemonStartError | InvalidStackStateError | StackAlreadyRunningError, - FileSystem.FileSystem | Path.Path | UnixHttpClient -> => - daemonLayer( - { - cacheRoot: opts.cacheRoot, - cwd: opts.cwd, - projectDir: opts.projectDir, - projectStateRoot: opts.projectStateRoot, - name: opts.name, - ...opts.stackConfig, - }, - opts.daemonEntryPoint, - ); - export async function createStack( config: StackConfig | undefined, platformFactory: PlatformFactory, diff --git a/packages/stack/src/daemon-node.ts b/packages/stack/src/daemon-node.ts index 387b2dff11..7c34ac6c70 100644 --- a/packages/stack/src/daemon-node.ts +++ b/packages/stack/src/daemon-node.ts @@ -4,9 +4,10 @@ import { createServer } from "node:http"; import { Effect, Layer } from "effect"; import { runDaemon } from "./daemon.ts"; -// Live child-process entrypoint for Node root consumers. `node.ts` resolves this module by file URL -// and passes its filesystem path to daemonLayer, so it is deliberately not a package export. The -// `knip.entry` declaration in package.json preserves this file-URL-only reachability. +// Live child-process entrypoint for Node Effect consumers. The internal Node platform adapter +// resolves this module by file URL and passes its filesystem path to daemonLayer, so it is +// deliberately not a package export. The `knip.entry` declaration in package.json preserves this +// file-URL-only reachability; see the matching note in node.ts. runDaemon( ({ apiPort, releaseApiPort }) => Layer.mergeAll( diff --git a/packages/stack/src/daemon.ts b/packages/stack/src/daemon.ts index 30361ecb92..0c64553060 100644 --- a/packages/stack/src/daemon.ts +++ b/packages/stack/src/daemon.ts @@ -23,12 +23,12 @@ export interface DaemonStartMessage { readonly socketPath: string; } -export interface DaemonStartedMessage { +interface DaemonStartedMessage { readonly type: "started"; readonly state: StackState; } -export interface DaemonErrorMessage { +interface DaemonErrorMessage { readonly type: "error"; readonly message: string; } diff --git a/packages/stack/src/effect-bun.ts b/packages/stack/src/effect-bun.ts new file mode 100644 index 0000000000..e4ef00edf9 --- /dev/null +++ b/packages/stack/src/effect-bun.ts @@ -0,0 +1,20 @@ +// @supabase/stack/effect — Bun-bound Effect interfaces and consumer layers. + +export * from "./effect.ts"; + +import type { PortLease } from "./PortAllocator.ts"; +import type { ResolvedStackConfig } from "./StackConfig.ts"; +import type { DaemonConfigInput } from "./StackConfigResolver.ts"; +import { + daemonLayer as daemonLayerForPlatform, + foregroundLayer as foregroundLayerForPlatform, +} from "./layers.ts"; +import { daemonEntryPoint, platformFactory, unixHttpClientLayer } from "./platform-bun.ts"; + +export { unixHttpClientLayer }; + +export const foregroundLayer = (config: ResolvedStackConfig, portLease: PortLease) => + foregroundLayerForPlatform(config, platformFactory, portLease); + +export const daemonLayer = (input: DaemonConfigInput) => + daemonLayerForPlatform(input, daemonEntryPoint); diff --git a/packages/stack/src/effect-node.ts b/packages/stack/src/effect-node.ts new file mode 100644 index 0000000000..fb60982fae --- /dev/null +++ b/packages/stack/src/effect-node.ts @@ -0,0 +1,20 @@ +// @supabase/stack/effect — Node-bound Effect interfaces and consumer layers. + +export * from "./effect.ts"; + +import type { PortLease } from "./PortAllocator.ts"; +import type { ResolvedStackConfig } from "./StackConfig.ts"; +import type { DaemonConfigInput } from "./StackConfigResolver.ts"; +import { + daemonLayer as daemonLayerForPlatform, + foregroundLayer as foregroundLayerForPlatform, +} from "./layers.ts"; +import { daemonEntryPoint, platformFactory, unixHttpClientLayer } from "./platform-node.ts"; + +export { unixHttpClientLayer }; + +export const foregroundLayer = (config: ResolvedStackConfig, portLease: PortLease) => + foregroundLayerForPlatform(config, platformFactory, portLease); + +export const daemonLayer = (input: DaemonConfigInput) => + daemonLayerForPlatform(input, daemonEntryPoint); diff --git a/packages/stack/src/effect.ts b/packages/stack/src/effect.ts index 5fe331ce8c..585bf00eaf 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -1,5 +1,4 @@ -// @supabase/stack/effect — advanced Effect and low-level APIs. -// Platform-agnostic: pass platformFactory/daemonEntryPoint from @supabase/stack. +// Platform-agnostic Effect contracts re-exported by the conditional @supabase/stack/effect entry. export type { LogEntry } from "@supabase/process-compose"; export type { StackServiceStatus } from "./StackServiceState.ts"; @@ -26,9 +25,6 @@ export { postgrestAssetName, } from "./Platform.ts"; -export type { BinarySpec } from "./BinaryResolver.ts"; -export { BinaryResolver } from "./BinaryResolver.ts"; - export type { ServiceResolution } from "./StackPreparation.ts"; export type { PrefetchOptions, PrefetchResult } from "./prefetch.ts"; @@ -39,7 +35,6 @@ export { defaultPublishableKey, defaultSecretKey, generateJwt, - JwtGenerator, } from "./JwtGenerator.ts"; export type { LocalCredentials, @@ -78,8 +73,6 @@ export { reservePorts, } from "./PortAllocator.ts"; -export type { ProxyConfig } from "./ApiProxy.ts"; -export { ApiProxy } from "./ApiProxy.ts"; export type { AnalyticsConfig, AuthConfig, @@ -116,7 +109,6 @@ export type { VectorConfig, } from "./StackConfig.ts"; export { DEFAULT_STACK_READINESS_POLICY, resolveReadinessPolicy } from "./StackConfig.ts"; -export { StackBuilder } from "./StackBuilder.ts"; export type { EdgeRuntimeReloadConfig, StackInfo } from "./Stack.ts"; export { EdgeRuntimeReloadConfigSchema, Stack } from "./Stack.ts"; @@ -187,18 +179,6 @@ export { stackMetadata, } from "./StackMetadata.ts"; -export { DaemonServer } from "./DaemonServer.ts"; -export { RemoteStack } from "./RemoteStack.ts"; -export { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; - -export type { - PlatformFactory, - PlatformFactoryOptions, - PlatformLayer, - PlatformServices, - StackHandle, -} from "./createStack.ts"; -export { createStack, projectDaemonLayer } from "./createStack.ts"; export type { ResolvedDaemonConfig } from "./StackConfig.ts"; export { defaultManagedStackName, @@ -206,7 +186,7 @@ export { resolveDaemonConfig, } from "./StackConfigResolver.ts"; -export { connectLayer, DaemonStartError, daemonLayer, foregroundLayer } from "./layers.ts"; +export { connectLayer, DaemonStartError } from "./layers.ts"; export type { ManagedStack } from "./managed-stack.ts"; export { resolveManagedStack } from "./managed-stack.ts"; @@ -218,11 +198,3 @@ export { resolveStackSummary, stopDaemon, } from "./discovery.ts"; - -export type { - DaemonErrorMessage, - DaemonHttpServerFactory, - DaemonMessage, - DaemonStartedMessage, - DaemonStartMessage, -} from "./daemon.ts"; diff --git a/packages/stack/src/entrypoints.unit.test.ts b/packages/stack/src/entrypoints.unit.test.ts index 61d8f0f41b..59f1aa4044 100644 --- a/packages/stack/src/entrypoints.unit.test.ts +++ b/packages/stack/src/entrypoints.unit.test.ts @@ -1,23 +1,71 @@ import { readFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; +import { describe, expect, expectTypeOf, it } from "vitest"; +import * as bunRoot from "./bun.ts"; +import * as bunEffect from "./effect-bun.ts"; +import * as nodeEffect from "./effect-node.ts"; +import * as nodeRoot from "./node.ts"; +import type { StackHandle } from "./createStack.ts"; +import * as testing from "./testing.ts"; -import { describe, expect, it } from "vitest"; +const INTERNAL_EFFECT_EXPORTS = [ + "ApiProxy", + "BinaryResolver", + "DaemonServer", + "JwtGenerator", + "RemoteStack", + "StackBuilder", + "UnixHttpClient", + "createStack", + "projectDaemonLayer", +] as const; describe("@supabase/stack entrypoints", () => { - it("ships conditional root exports and keeps only the effect subpath", () => { + it("declares only intentional package entrypoints", () => { const srcDir = dirname(fileURLToPath(import.meta.url)); const packageJson = JSON.parse(readFileSync(join(srcDir, "../package.json"), "utf8")) as { readonly exports: Record>; + readonly knip: { readonly entry: ReadonlyArray }; }; - expect(packageJson.exports["."]).toEqual({ - bun: "./src/bun.ts", - default: "./src/node.ts", + expect(packageJson.exports).toEqual({ + ".": { + bun: "./src/bun.ts", + default: "./src/node.ts", + }, + "./effect": { + bun: "./src/effect-bun.ts", + default: "./src/effect-node.ts", + }, + "./testing": "./src/testing.ts", + "./daemon-bun": "./src/daemon-bun.ts", }); - expect(packageJson.exports["./effect"]).toBe("./src/effect.ts"); - expect(packageJson.exports["./bun"]).toBeUndefined(); - expect(packageJson.exports["./node"]).toBeUndefined(); + expect(packageJson.exports["./daemon-node"]).toBeUndefined(); expect(packageJson.exports["./internals"]).toBeUndefined(); + expect(packageJson.knip.entry).toContain("src/daemon-node.ts"); + }); + + it("keeps the root runtime surface Promise-only", () => { + expect(Object.keys(nodeRoot).sort()).toEqual(["createStack", "prefetch"]); + expect(Object.keys(bunRoot).sort()).toEqual(["createStack", "prefetch"]); + expectTypeOf(nodeRoot.createStack).returns.toEqualTypeOf>(); + expectTypeOf(bunRoot.createStack).returns.toEqualTypeOf>(); + }); + + it("binds consumer Effect layers without exposing implementation tags", () => { + for (const entrypoint of [nodeEffect, bunEffect]) { + expect(entrypoint).toHaveProperty("connectLayer"); + expect(entrypoint).toHaveProperty("daemonLayer"); + expect(entrypoint).toHaveProperty("foregroundLayer"); + expect(entrypoint).toHaveProperty("unixHttpClientLayer"); + for (const name of INTERNAL_EFFECT_EXPORTS) { + expect(entrypoint).not.toHaveProperty(name); + } + } + }); + + it("isolates consumer test seams in the testing entry", () => { + expect(Object.keys(testing).sort()).toEqual(["DaemonServer", "UnixHttpClient"]); }); }); diff --git a/packages/stack/src/index.ts b/packages/stack/src/index.ts index bbba381b23..2be1a25d85 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -34,4 +34,3 @@ export type { ResolvedFunction, ResolvedFunctionsBundle, } from "./functions.ts"; -export { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; diff --git a/packages/stack/src/node.ts b/packages/stack/src/node.ts index aedf7ab1f1..8be38e7ce1 100644 --- a/packages/stack/src/node.ts +++ b/packages/stack/src/node.ts @@ -1,145 +1,23 @@ import { NodeServices } from "@effect/platform-node"; -import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; -import { createServer } from "node:http"; -import * as Http from "node:http"; -import { Readable } from "node:stream"; -import { fileURLToPath } from "node:url"; import { Effect, Layer } from "effect"; import { FetchHttpClient } from "effect/unstable/http"; import { BinaryResolver } from "./BinaryResolver.ts"; -import { - createStack as createStackCore, - type PlatformFactory, - type StackHandle, -} from "./createStack.ts"; +import { createStack as createStackCore, type StackHandle } from "./createStack.ts"; import { prefetch as prefetchEffect, type PrefetchOptions, type PrefetchResult, } from "./prefetch.ts"; import { defaultCacheRoot } from "./paths.ts"; +import { platformFactory } from "./platform-node.ts"; import { StackPreparation } from "./StackPreparation.ts"; import type { StackConfig } from "./StackConfig.ts"; -import { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; - -const mergeBodyHeaders = ( - headersInit: RequestInit["headers"] | undefined, - bodyHeaders: Headers, -): Headers => { - const headers = new Headers(headersInit); - for (const [key, value] of bodyHeaders.entries()) { - if (!headers.has(key)) { - headers.set(key, value); - } - } - return headers; -}; - -const toOutgoingHeaders = (headers: Headers): Http.OutgoingHttpHeaders => - Object.fromEntries(headers.entries()); - -const toResponseHeaders = (headers: Http.IncomingHttpHeaders): Headers => { - const responseHeaders = new Headers(); - for (const [key, value] of Object.entries(headers)) { - if (value === undefined) { - continue; - } - if (Array.isArray(value)) { - for (const item of value) { - responseHeaders.append(key, item); - } - continue; - } - responseHeaders.set(key, value); - } - return responseHeaders; -}; - -const encodeRequest = async ( - init: RequestInit | undefined, -): Promise<{ - readonly body: Uint8Array | undefined; - readonly headers: Http.OutgoingHttpHeaders; -}> => { - if (init?.body == null) { - return { - body: undefined, - headers: toOutgoingHeaders(new Headers(init?.headers)), - }; - } - - const bodyResponse = new Response(init.body); - const headers = mergeBodyHeaders(init.headers, bodyResponse.headers); - return { - body: new Uint8Array(await bodyResponse.arrayBuffer()), - headers: toOutgoingHeaders(headers), - }; -}; - -const toWebResponse = (response: Http.IncomingMessage): Response => - new Response( - response.statusCode === 204 || response.statusCode === 304 ? null : Readable.toWeb(response), - { - status: response.statusCode ?? 200, - statusText: response.statusMessage ?? "", - headers: toResponseHeaders(response.headers), - }, - ); - -export const unixHttpClientLayer = Layer.succeed(UnixHttpClient, { - request: (socketPath, path, init) => - Effect.tryPromise({ - try: async () => { - const { body, headers } = await encodeRequest(init); - return await new Promise((resolve, reject) => { - const request = Http.request( - { - socketPath, - path, - method: init?.method ?? "GET", - headers, - signal: init?.signal ?? undefined, - }, - (response) => { - resolve(toWebResponse(response)); - }, - ); - - request.on("error", reject); - request.end(body); - }); - }, - catch: (cause) => new UnixHttpClientError({ socketPath, path, cause }), - }), -}); - -// --------------------------------------------------------------------------- -// Platform values — for use with Effect layer factories -// --------------------------------------------------------------------------- - -/** Node platform factory for use with foregroundLayer / daemonLayer. */ -export const platformFactory: PlatformFactory = ({ apiPort, releaseApiPort }) => - Layer.mergeAll( - NodeServices.layer, - Layer.unwrap( - releaseApiPort.pipe( - Effect.as(NodeHttpServer.layer(() => createServer(), { port: apiPort }).pipe(Layer.orDie)), - ), - ), - ); /** - * Path to the Node daemon entry point for use with daemonLayer. - * - * `daemon-node.ts` is intentionally reached by this file URL instead of a package export. Keep the - * matching `knip.entry` in package.json when changing this path; static import analysis cannot see - * the child-process entrypoint. + * The Node daemon bootstrap is deliberately not exported from the package. The conditional Effect + * entry resolves `daemon-node.ts` by file URL through the internal platform adapter. Keep + * `src/daemon-node.ts` in package.json's `knip.entry` list: static imports cannot see that fork target. */ -export const daemonEntryPoint: string = fileURLToPath(new URL("./daemon-node.ts", import.meta.url)); - -// --------------------------------------------------------------------------- -// Promise API — convenience wrappers for non-Effect consumers -// --------------------------------------------------------------------------- export async function createStack(config?: StackConfig): Promise { return createStackCore(config, platformFactory); diff --git a/packages/stack/src/platform-bun.ts b/packages/stack/src/platform-bun.ts new file mode 100644 index 0000000000..96d6252165 --- /dev/null +++ b/packages/stack/src/platform-bun.ts @@ -0,0 +1,30 @@ +import { BunServices } from "@effect/platform-bun"; +import * as BunHttpServer from "@effect/platform-bun/BunHttpServer"; +import { fileURLToPath } from "node:url"; +import { Effect, Layer } from "effect"; +import type { PlatformFactory } from "./createStack.ts"; +import { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; + +interface BunUnixRequestInit extends RequestInit { + readonly unix: string; +} + +export const unixHttpClientLayer = Layer.succeed(UnixHttpClient, { + request: (socketPath, path, init) => + Effect.tryPromise({ + try: () => { + const requestInit: BunUnixRequestInit = { ...init, unix: socketPath }; + return fetch(`http://localhost${path}`, requestInit); + }, + catch: (cause) => new UnixHttpClientError({ socketPath, path, cause }), + }), +}); + +export const platformFactory: PlatformFactory = ({ apiPort, releaseApiPort }) => + Layer.mergeAll( + BunServices.layer, + Layer.unwrap(releaseApiPort.pipe(Effect.as(BunHttpServer.layer({ port: apiPort })))), + ); + +/** Internal source-mode child target. Compiled CLI dispatch still uses the daemon-bun export. */ +export const daemonEntryPoint = fileURLToPath(new URL("./daemon-bun.ts", import.meta.url)); diff --git a/packages/stack/src/platform-node.ts b/packages/stack/src/platform-node.ts new file mode 100644 index 0000000000..bdcc9c85b3 --- /dev/null +++ b/packages/stack/src/platform-node.ts @@ -0,0 +1,109 @@ +import { NodeServices } from "@effect/platform-node"; +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import { createServer } from "node:http"; +import * as Http from "node:http"; +import { Readable } from "node:stream"; +import { fileURLToPath } from "node:url"; +import { Effect, Layer } from "effect"; +import type { PlatformFactory } from "./createStack.ts"; +import { UnixHttpClient, UnixHttpClientError } from "./UnixHttpClient.ts"; + +const mergeBodyHeaders = ( + headersInit: RequestInit["headers"] | undefined, + bodyHeaders: Headers, +): Headers => { + const headers = new Headers(headersInit); + for (const [key, value] of bodyHeaders.entries()) { + if (!headers.has(key)) { + headers.set(key, value); + } + } + return headers; +}; + +const toOutgoingHeaders = (headers: Headers): Http.OutgoingHttpHeaders => + Object.fromEntries(headers.entries()); + +const toResponseHeaders = (headers: Http.IncomingHttpHeaders): Headers => { + const responseHeaders = new Headers(); + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) continue; + if (Array.isArray(value)) { + for (const item of value) responseHeaders.append(key, item); + continue; + } + responseHeaders.set(key, value); + } + return responseHeaders; +}; + +const encodeRequest = async ( + init: RequestInit | undefined, +): Promise<{ + readonly body: Uint8Array | undefined; + readonly headers: Http.OutgoingHttpHeaders; +}> => { + if (init?.body == null) { + return { + body: undefined, + headers: toOutgoingHeaders(new Headers(init?.headers)), + }; + } + + const bodyResponse = new Response(init.body); + const headers = mergeBodyHeaders(init.headers, bodyResponse.headers); + return { + body: new Uint8Array(await bodyResponse.arrayBuffer()), + headers: toOutgoingHeaders(headers), + }; +}; + +const toWebResponse = (response: Http.IncomingMessage): Response => + new Response( + response.statusCode === 204 || response.statusCode === 304 ? null : Readable.toWeb(response), + { + status: response.statusCode ?? 200, + statusText: response.statusMessage ?? "", + headers: toResponseHeaders(response.headers), + }, + ); + +export const unixHttpClientLayer = Layer.succeed(UnixHttpClient, { + request: (socketPath, path, init) => + Effect.tryPromise({ + try: async () => { + const { body, headers } = await encodeRequest(init); + return await new Promise((resolve, reject) => { + const request = Http.request( + { + socketPath, + path, + method: init?.method ?? "GET", + headers, + signal: init?.signal ?? undefined, + }, + (response) => { + resolve(toWebResponse(response)); + }, + ); + + request.on("error", reject); + request.end(body); + }); + }, + catch: (cause) => new UnixHttpClientError({ socketPath, path, cause }), + }), +}); + +export const platformFactory: PlatformFactory = ({ apiPort, releaseApiPort }) => + Layer.mergeAll( + NodeServices.layer, + Layer.unwrap( + releaseApiPort.pipe( + Effect.as(NodeHttpServer.layer(() => createServer(), { port: apiPort }).pipe(Layer.orDie)), + ), + ), + ); + +/** Internal child-process target. It is intentionally absent from package exports. */ +export const daemonEntryPoint = fileURLToPath(new URL("./daemon-node.ts", import.meta.url)); diff --git a/packages/stack/src/testing.ts b/packages/stack/src/testing.ts new file mode 100644 index 0000000000..206459eeeb --- /dev/null +++ b/packages/stack/src/testing.ts @@ -0,0 +1,3 @@ +/** Test-only service tags for building deterministic consumer layers. */ +export { DaemonServer } from "./DaemonServer.ts"; +export { UnixHttpClient } from "./UnixHttpClient.ts"; From e6d8e8b20287d7da5f27543b6220a8818a8f411b Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 17:59:40 +0200 Subject: [PATCH 16/26] test(cli): refine parity presence rules --- .../next/config/local-stack-config-parity.ts | 51 ++++++++++++++----- .../local-stack-config-parity.unit.test.ts | 7 ++- 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/apps/cli/src/next/config/local-stack-config-parity.ts b/apps/cli/src/next/config/local-stack-config-parity.ts index d45b6f8342..af5daceec4 100644 --- a/apps/cli/src/next/config/local-stack-config-parity.ts +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -5,11 +5,15 @@ import type { ProjectConfig } from "@supabase/config"; * * `presence` tells the future launch resolver how to determine whether a field * affects the local runtime. Most schema defaults erase the distinction between - * an omitted field and an explicitly configured default value. Secrets need an - * additional check: generated `env(...)` placeholders that did not resolve and - * secrets inside disabled subtrees do not affect the runtime. + * an omitted field and an explicitly configured default value. Generated + * disabled provider stubs and secrets inside disabled subtrees do not affect the + * runtime; unresolved `env(...)` placeholders do not provide concrete secrets. */ -type LocalStackConfigParityPresence = "decoded-value" | "effective-secret" | "raw-document"; +type LocalStackConfigParityPresence = + | "decoded-value" + | "effective-secret" + | "enabled-subtree" + | "raw-document"; type LocalStackConfigParityDecision = | { @@ -69,6 +73,13 @@ const unsupportedSecretRuntimeField: LocalStackConfigParityDecision = { "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 unsupportedEnabledProviderField: LocalStackConfigParityDecision = { + _tag: "unsupported-blocking", + presence: "enabled-subtree", + rationale: + "This setting changes local authentication behavior only when its provider is effectively enabled; generated disabled provider stubs do not count.", +}; + const mappedAutoExposeNewTables: LocalStackConfigParityDecision = { _tag: "mapped", presence: "raw-document", @@ -138,13 +149,13 @@ const unsupportedFutureRuntimeField: LocalStackConfigParityDecision = { }; const authExternalProviderParity = { - enabled: unsupportedRuntimeField, - client_id: unsupportedRuntimeField, + enabled: unsupportedEnabledProviderField, + client_id: unsupportedEnabledProviderField, secret: unsupportedSecretRuntimeField, - url: unsupportedRuntimeField, - redirect_uri: unsupportedRuntimeField, - skip_nonce_check: unsupportedRuntimeField, - email_optional: unsupportedRuntimeField, + url: unsupportedEnabledProviderField, + redirect_uri: unsupportedEnabledProviderField, + skip_nonce_check: unsupportedEnabledProviderField, + email_optional: unsupportedEnabledProviderField, } satisfies Record; type AuthExternalParity = { @@ -507,13 +518,29 @@ const localStackConfigParity = { max_namespaces: unsupportedRuntimeField, max_tables: unsupportedRuntimeField, max_catalogs: unsupportedRuntimeField, - buckets: unsupportedRuntimeField, + buckets: { + "*": { + decision: unsupportedRuntimeField, + children: {} satisfies Record< + keyof ProjectConfig["storage"]["analytics"]["buckets"][string], + Node + >, + }, + }, } satisfies Record, vector: { enabled: unsupportedRuntimeField, max_buckets: unsupportedRuntimeField, max_indexes: unsupportedRuntimeField, - buckets: unsupportedRuntimeField, + buckets: { + "*": { + decision: unsupportedRuntimeField, + children: {} satisfies Record< + keyof ProjectConfig["storage"]["vector"]["buckets"][string], + Node + >, + }, + }, } satisfies Record, } satisfies Record, studio: { 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 index b94884bcb0..39f79f958b 100644 --- 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 @@ -63,15 +63,18 @@ describe("localStackConfigParity", () => { ]); }); - it("preserves raw-document requirements for presence-sensitive sections", () => { + it("preserves 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("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("raw-document"); expect(byPath.get("auth.sms.twilio.enabled")?.presence).toBe("raw-document"); 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("raw-document"); + expect(byPath.get("storage.vector.buckets.*")?.presence).toBe("raw-document"); expect(byPath.get("experimental.webhooks.enabled")?.presence).toBe("raw-document"); expect(byPath.get("auth.external.apple.secret")?.presence).toBe("effective-secret"); expect(byPath.get("studio.openai_api_key")?.presence).toBe("effective-secret"); From dfa4c5c44b903f728d1b6df5294209eb072b65e6 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 18:33:45 +0200 Subject: [PATCH 17/26] refactor(stack): separate function configuration from activation --- .../src/next/commands/start/start.handler.ts | 2 +- .../commands/start/start.integration.test.ts | 17 +- apps/cli/src/next/config/auth-stack-config.ts | 2 +- .../config/auth-stack-config.unit.test.ts | 28 +-- .../config/stack-config.integration.test.ts | 57 ++++++ apps/cli/tests/helpers/mocks.ts | 7 + apps/cli/tests/helpers/running-stack.ts | 4 + .../src/DaemonServer.integration.test.ts | 31 ++++ packages/stack/src/DaemonServer.ts | 40 ++++- packages/stack/src/LocalStack.ts | 9 + .../stack/src/RemoteStack.integration.test.ts | 17 ++ packages/stack/src/RemoteStack.ts | 15 ++ packages/stack/src/Stack.ts | 5 + packages/stack/src/Stack.unit.test.ts | 66 +++++++ .../src/UnixSocketSse.integration.test.ts | 1 + packages/stack/src/createStack.ts | 4 +- packages/stack/src/effect.ts | 2 + packages/stack/src/functions.ts | 17 +- packages/stack/src/functions.unit.test.ts | 123 ++++++++++++- packages/stack/src/index.ts | 1 + .../stack/src/services/edge-runtime-main.ts | 162 ++++++++++++++---- 21 files changed, 540 insertions(+), 70 deletions(-) diff --git a/apps/cli/src/next/commands/start/start.handler.ts b/apps/cli/src/next/commands/start/start.handler.ts index 7ca68e408c..421664ddda 100644 --- a/apps/cli/src/next/commands/start/start.handler.ts +++ b/apps/cli/src/next/commands/start/start.handler.ts @@ -58,7 +58,7 @@ export const start = Effect.fnUntraced(function* (flags: StartFlags) { } if (functionsState.bundle !== undefined) { - yield* stack.reloadFunctions({ functions: functionsState.bundle }); + yield* stack.configureFunctions({ functions: functionsState.bundle }); } let result: void; 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 5ad7066485..edbf51cfbd 100644 --- a/apps/cli/src/next/commands/start/start.integration.test.ts +++ b/apps/cli/src/next/commands/start/start.integration.test.ts @@ -223,6 +223,18 @@ 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" }, @@ -242,8 +254,9 @@ describe("start", () => { return Effect.gen(function* () { yield* start(backgroundFlags); - expect(stack.functionsReloads).toEqual([{ functions: bundle }]); - expect(stack.operations.slice(0, 2)).toEqual(["reload-functions", "start"]); + 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"); diff --git a/apps/cli/src/next/config/auth-stack-config.ts b/apps/cli/src/next/config/auth-stack-config.ts index ce2f76657b..0bcb377f23 100644 --- a/apps/cli/src/next/config/auth-stack-config.ts +++ b/apps/cli/src/next/config/auth-stack-config.ts @@ -454,7 +454,7 @@ export const translateAuthStackConfig = Effect.fnUntraced(function* (input: { const jwtSecret = flatString("jwt_secret", auth.jwt_secret) ?? defaultJwtSecret; const signingKeysPath = flatString("signing_keys_path", auth.signing_keys_path); let signing: LocalJwtSigningMaterial; - if (authEnabled && signingKeysPath !== undefined && signingKeysPath.length > 0) { + if (signingKeysPath !== undefined && signingKeysPath.length > 0) { signing = { _tag: "AsymmetricJwtKeys", keys: yield* readSigningKeys(input.configDir, signingKeysPath), 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 index 1c395b83d7..41e8645cb0 100644 --- a/apps/cli/src/next/config/auth-stack-config.unit.test.ts +++ b/apps/cli/src/next/config/auth-stack-config.unit.test.ts @@ -263,19 +263,21 @@ describe("translateAuthStackConfig", () => { }); }); - it("does not read signing keys when Auth is excluded", async () => { - const result = await Effect.runPromise( - translateAuthStackConfig({ - configDir: "/missing", - authEnabled: false, - projectEnvironment: null, - projectConfig: decodeProjectConfig({ - auth: { signing_keys_path: "missing.json" }, + it("validates signing keys even when Auth is excluded", async () => { + await expect( + Effect.runPromise( + translateAuthStackConfig({ + configDir: "/missing", + authEnabled: false, + projectEnvironment: null, + projectConfig: decodeProjectConfig({ + auth: { signing_keys_path: "missing.json" }, + }), }), - }), - ); - - expect(result.auth).toBe(false); - expect(result.credentials.signing?._tag).toBe("SymmetricJwtSecret"); + ), + ).rejects.toMatchObject({ + _tag: "AuthStackConfigError", + path: "auth.signing_keys_path", + }); }); }); diff --git a/apps/cli/src/next/config/stack-config.integration.test.ts b/apps/cli/src/next/config/stack-config.integration.test.ts index 26632d885e..005567eabc 100644 --- a/apps/cli/src/next/config/stack-config.integration.test.ts +++ b/apps/cli/src/next/config/stack-config.integration.test.ts @@ -170,6 +170,63 @@ describe("local stack launch config", () => { } }); + 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"); diff --git a/apps/cli/tests/helpers/mocks.ts b/apps/cli/tests/helpers/mocks.ts index 97dc13600c..6a08bab0d9 100644 --- a/apps/cli/tests/helpers/mocks.ts +++ b/apps/cli/tests/helpers/mocks.ts @@ -615,6 +615,7 @@ export function mockStack( ) { let started = false; let stopped = false; + const functionsConfigurations: FunctionsReloadConfig[] = []; const functionsReloads: FunctionsReloadConfig[] = []; const operations: string[] = []; const startDeferred = Deferred.makeUnsafe(); @@ -681,6 +682,11 @@ export function mockStack( startService: () => Effect.void, stopService: () => Effect.void, restartService: () => Effect.void, + configureFunctions: (config) => + Effect.sync(() => { + functionsConfigurations.push(config); + operations.push("configure-functions"); + }), reloadFunctions: (config) => Effect.sync(() => { functionsReloads.push(config ?? {}); @@ -754,6 +760,7 @@ export function mockStack( get stopped() { return stopped; }, + functionsConfigurations, functionsReloads, operations, emitStateChange(change: { name: string; status: StackServiceState["status"] }) { 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/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/LocalStack.ts b/packages/stack/src/LocalStack.ts index 7f507424ef..7e858807b3 100644 --- a/packages/stack/src/LocalStack.ts +++ b/packages/stack/src/LocalStack.ts @@ -768,6 +768,15 @@ export const localStackLayer = ( }).pipe(withLifecycleLock); yield* waitForTargets(started); }).pipe((effect) => withReadinessPolicy(effect, name), 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/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 cf8bf77ba8..feecf3c14b 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -206,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"); 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/effect.ts b/packages/stack/src/effect.ts index 585bf00eaf..8a74112674 100644 --- a/packages/stack/src/effect.ts +++ b/packages/stack/src/effect.ts @@ -113,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, @@ -121,6 +122,7 @@ export type { export { clearFunctionsRuntimeConfig, configureFunctionsRuntime, + FunctionsConfigureConfigSchema, FunctionsReloadConfigSchema, functionsRuntimeConfigFileName, functionsRuntimeConfigPath, 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 11def36a47..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, @@ -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", @@ -196,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 }, ); @@ -223,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 }, ); @@ -236,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 2be1a25d85..10bfd42811 100644 --- a/packages/stack/src/index.ts +++ b/packages/stack/src/index.ts @@ -29,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/edge-runtime-main.ts b/packages/stack/src/services/edge-runtime-main.ts index b0efe41ad7..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; + } +} + +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(secret: string, jwt: string) { +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}`, }); } From 42b28c7cd2dcf90ff5b3b331c06cfa40333a3f34 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 18:32:48 +0200 Subject: [PATCH 18/26] feat(cli): close local stack config parity gaps --- .../src/next/config/analytics-stack-config.ts | 16 +- apps/cli/src/next/config/core-stack-config.ts | 132 ++++++------ .../config/data-plane-stack-config-values.ts | 85 +++----- .../next/config/data-plane-stack-config.ts | 8 +- .../data-plane-stack-config.unit.test.ts | 89 +++++++- .../next/config/local-stack-config-parity.ts | 56 ++++- .../local-stack-config-parity.unit.test.ts | 37 ++-- .../next/config/local-stack-config-values.ts | 135 ++++++++++++ .../src/next/config/pooler-stack-config.ts | 9 +- .../src/next/config/realtime-stack-config.ts | 7 +- apps/cli/src/next/config/stack-config.ts | 54 ++++- .../src/next/config/stack-config.unit.test.ts | 193 +++++++++++++++++- .../src/next/config/storage-stack-config.ts | 16 +- .../src/next/config/studio-stack-config.ts | 6 +- packages/config/src/io.ts | 44 +++- packages/config/src/io.unit.test.ts | 3 + packages/stack/src/Stack.unit.test.ts | 37 ++++ packages/stack/src/StackBuilder.ts | 23 ++- packages/stack/src/StackBuilder.unit.test.ts | 109 ++++++++++ packages/stack/src/services/postgres-init.ts | 35 ++++ .../stack/src/services/services.unit.test.ts | 27 +++ 21 files changed, 930 insertions(+), 191 deletions(-) create mode 100644 apps/cli/src/next/config/local-stack-config-values.ts diff --git a/apps/cli/src/next/config/analytics-stack-config.ts b/apps/cli/src/next/config/analytics-stack-config.ts index c0602f0ae0..a52ea69789 100644 --- a/apps/cli/src/next/config/analytics-stack-config.ts +++ b/apps/cli/src/next/config/analytics-stack-config.ts @@ -1,4 +1,4 @@ -import type { ProjectConfig, ProjectEnvironment } from "@supabase/config"; +import type { LoadedProjectConfig, ProjectConfig, ProjectEnvironment } from "@supabase/config"; import type { AnalyticsConfig } from "@supabase/stack/effect"; import { resolve } from "node:path"; import { @@ -16,20 +16,21 @@ function required(value: string | undefined, path: string): string { } 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, - envName: "SUPABASE_ANALYTICS_ENABLED", configured: input.config.enabled, path: "analytics.enabled", }); const backend = resolveEnumOverride<"postgres" | "bigquery">({ + loaded: input.loaded, environment: input.environment, - envName: "SUPABASE_ANALYTICS_BACKEND", configured: input.config.backend, path: "analytics.backend", values: ["postgres", "bigquery"], @@ -39,17 +40,19 @@ export function resolveAnalyticsStackConfig(input: { ? { projectId: required( environmentOverride( - "SUPABASE_ANALYTICS_GCP_PROJECT_ID", + "analytics.gcp_project_id", input.config.gcp_project_id, input.environment, + input.loaded, ), "analytics.gcp_project_id", ), projectNumber: required( environmentOverride( - "SUPABASE_ANALYTICS_GCP_PROJECT_NUMBER", + "analytics.gcp_project_number", input.config.gcp_project_number, input.environment, + input.loaded, ), "analytics.gcp_project_number", ), @@ -57,9 +60,10 @@ export function resolveAnalyticsStackConfig(input: { input.configDir, required( environmentOverride( - "SUPABASE_ANALYTICS_GCP_JWT_PATH", + "analytics.gcp_jwt_path", input.config.gcp_jwt_path, input.environment, + input.loaded, ), "analytics.gcp_jwt_path", ), diff --git a/apps/cli/src/next/config/core-stack-config.ts b/apps/cli/src/next/config/core-stack-config.ts index 0df13cdead..c6b5eccc73 100644 --- a/apps/cli/src/next/config/core-stack-config.ts +++ b/apps/cli/src/next/config/core-stack-config.ts @@ -1,6 +1,13 @@ -import type { ProjectConfig, ProjectEnvironment } from "@supabase/config"; +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", @@ -25,21 +32,6 @@ export class LocalStackConfigError extends Data.TaggedError("LocalStackConfigErr readonly paths: ReadonlyArray; }> {} -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 invalidLocalStackConfig(path: string, suggestion: string): LocalStackConfigError { return new LocalStackConfigError({ detail: `Invalid local stack configuration at ${path}.`, @@ -49,29 +41,23 @@ export function invalidLocalStackConfig(path: string, suggestion: string): Local } function environmentOverride( - name: string, + path: string, configured: string | undefined, environment: ProjectEnvironment | null, + loaded: LoadedProjectConfig | null, ): string | undefined { - const value = environment?.values[name]; - if (value === undefined || value.length === 0) return configured; - 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; + return effectiveEnvironmentOverride({ loaded, environment, path }) ?? configured; } function resolveBoolean(input: { + readonly loaded: LoadedProjectConfig | null; readonly environment: ProjectEnvironment | null; - readonly envName: string; readonly configured: boolean; readonly path: string; }): boolean { - const override = environmentOverride(input.envName, undefined, input.environment); + const override = effectiveEnvironmentOverride(input); if (override === undefined) return input.configured; - const resolved = GO_BOOLEAN_VALUES[override]; + const resolved = parseGoBoolean(override); if (resolved === undefined) { throw invalidLocalStackConfig( input.path, @@ -104,13 +90,13 @@ function parseGoPort(value: string): number | undefined { } function resolvePort(input: { + readonly loaded: LoadedProjectConfig | null; readonly environment: ProjectEnvironment | null; - readonly envName: string; readonly configured: number | undefined; readonly path: string; readonly required?: boolean; }): number | undefined { - const override = environmentOverride(input.envName, undefined, input.environment); + const override = effectiveEnvironmentOverride(input); const resolved = override === undefined ? input.configured : parseGoPort(override); if (resolved === undefined && input.required !== true && override === undefined) return undefined; if ( @@ -149,6 +135,7 @@ function resolvePoolMode(value: string): "transaction" | "session" { } export function resolveCoreStackConfig(input: { + readonly loadedProjectConfig: LoadedProjectConfig | null; readonly projectConfig: ProjectConfig; readonly rawDocument?: Readonly>; readonly projectEnvironment: ProjectEnvironment | null; @@ -158,68 +145,58 @@ export function resolveCoreStackConfig(input: { const { projectConfig, projectEnvironment } = input; const excluded = new Set(input.exclude); const enabled = (params: { - readonly envName: string; readonly configured: boolean; readonly path: string; readonly excludedAs: ExcludedStackService; }) => resolveBoolean({ + loaded: input.loadedProjectConfig, environment: projectEnvironment, - envName: params.envName, configured: params.configured, path: params.path, }) && !excluded.has(params.excludedAs); const apiEnabled = enabled({ - envName: "SUPABASE_API_ENABLED", configured: projectConfig.api.enabled, path: "api.enabled", excludedAs: "postgrest", }); const authEnabled = enabled({ - envName: "SUPABASE_AUTH_ENABLED", configured: projectConfig.auth.enabled, path: "auth.enabled", excludedAs: "auth", }); const realtimeEnabled = enabled({ - envName: "SUPABASE_REALTIME_ENABLED", configured: projectConfig.realtime.enabled, path: "realtime.enabled", excludedAs: "realtime", }); const storageEnabled = enabled({ - envName: "SUPABASE_STORAGE_ENABLED", configured: projectConfig.storage.enabled, path: "storage.enabled", excludedAs: "storage", }); const mailpitEnabled = enabled({ - envName: "SUPABASE_LOCAL_SMTP_ENABLED", configured: projectConfig.local_smtp.enabled, path: "local_smtp.enabled", excludedAs: "mailpit", }); const studioEnabled = enabled({ - envName: "SUPABASE_STUDIO_ENABLED", configured: projectConfig.studio.enabled, path: "studio.enabled", excludedAs: "studio", }); const analyticsEnabled = enabled({ - envName: "SUPABASE_ANALYTICS_ENABLED", configured: projectConfig.analytics.enabled, path: "analytics.enabled", excludedAs: "analytics", }); const poolerEnabled = enabled({ - envName: "SUPABASE_DB_POOLER_ENABLED", configured: projectConfig.db.pooler.enabled, path: "db.pooler.enabled", excludedAs: "pooler", }); const edgeRuntimeEnabled = enabled({ - envName: "SUPABASE_EDGE_RUNTIME_ENABLED", configured: projectConfig.edge_runtime.enabled, path: "edge_runtime.enabled", excludedAs: "edge-runtime", @@ -230,72 +207,76 @@ export function resolveCoreStackConfig(input: { const imageTransformationEnabled = isRecord(imageTransformationSection) && resolveBoolean({ + loaded: input.loadedProjectConfig, environment: projectEnvironment, - envName: "SUPABASE_STORAGE_IMAGE_TRANSFORMATION_ENABLED", configured: projectConfig.storage.image_transformation?.enabled ?? false, path: "storage.image_transformation.enabled", }); const apiPort = resolvePort({ + loaded: input.loadedProjectConfig, environment: projectEnvironment, - envName: "SUPABASE_API_PORT", configured: projectConfig.api.port, path: "api.port", required: apiEnabled, }); const dbPort = resolvePort({ + loaded: input.loadedProjectConfig, environment: projectEnvironment, - envName: "SUPABASE_DB_PORT", configured: projectConfig.db.port, path: "db.port", required: true, }); const studioPort = resolvePort({ + loaded: input.loadedProjectConfig, environment: projectEnvironment, - envName: "SUPABASE_STUDIO_PORT", configured: projectConfig.studio.port, path: "studio.port", required: studioEnabled, }); const mailpitPort = resolvePort({ + loaded: input.loadedProjectConfig, environment: projectEnvironment, - envName: "SUPABASE_LOCAL_SMTP_PORT", configured: projectConfig.local_smtp.port, path: "local_smtp.port", required: mailpitEnabled, }); const mailpitSmtpPort = resolvePort({ + loaded: input.loadedProjectConfig, environment: projectEnvironment, - envName: "SUPABASE_LOCAL_SMTP_SMTP_PORT", configured: projectConfig.local_smtp.smtp_port, path: "local_smtp.smtp_port", }); const mailpitPop3Port = resolvePort({ + loaded: input.loadedProjectConfig, environment: projectEnvironment, - envName: "SUPABASE_LOCAL_SMTP_POP3_PORT", configured: projectConfig.local_smtp.pop3_port, path: "local_smtp.pop3_port", }); const analyticsPort = resolvePort({ + loaded: input.loadedProjectConfig, environment: projectEnvironment, - envName: "SUPABASE_ANALYTICS_PORT", configured: projectConfig.analytics.port, path: "analytics.port", required: analyticsEnabled, }); const poolerPort = resolvePort({ + loaded: input.loadedProjectConfig, environment: projectEnvironment, - envName: "SUPABASE_DB_POOLER_PORT", configured: projectConfig.db.pooler.port, path: "db.pooler.port", required: poolerEnabled, }); - const edgeRuntimeInspectorPort = resolvePort({ + const maxRowsOverride = effectiveEnvironmentOverride({ + loaded: input.loadedProjectConfig, environment: projectEnvironment, - envName: "SUPABASE_EDGE_RUNTIME_INSPECTOR_PORT", - configured: projectConfig.edge_runtime.inspector_port, - path: "edge_runtime.inspector_port", + 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, @@ -303,16 +284,32 @@ export function resolveCoreStackConfig(input: { postgres: serviceConfig(input.base.postgres, { port: dbPort }), postgrest: apiEnabled ? serviceConfig(input.base.postgrest, { - schemas: projectConfig.api.schemas, - extraSearchPath: projectConfig.api.extra_search_path, - maxRows: projectConfig.api.max_rows, + 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(projectConfig.edge_runtime.policy), - inspectorPort: edgeRuntimeInspectorPort, + policy: resolveEdgeRuntimePolicy( + effectiveString({ + loaded: input.loadedProjectConfig, + environment: projectEnvironment, + path: "edge_runtime.policy", + configured: projectConfig.edge_runtime.policy, + }), + ), }) : false, realtime: realtimeEnabled @@ -333,17 +330,23 @@ export function resolveCoreStackConfig(input: { mailpit: mailpitEnabled ? serviceConfig(input.base.mailpit, { port: mailpitPort, - ...(mailpitSmtpPort === undefined ? {} : { smtpPort: mailpitSmtpPort }), - ...(mailpitPop3Port === undefined ? {} : { pop3Port: mailpitPop3Port }), + ...(mailpitSmtpPort === undefined || mailpitSmtpPort === 0 + ? {} + : { smtpPort: mailpitSmtpPort }), + ...(mailpitPop3Port === undefined || mailpitPop3Port === 0 + ? {} + : { pop3Port: mailpitPop3Port }), adminEmail: environmentOverride( - "SUPABASE_LOCAL_SMTP_ADMIN_EMAIL", + "local_smtp.admin_email", projectConfig.local_smtp.admin_email, projectEnvironment, + input.loadedProjectConfig, ), senderName: environmentOverride( - "SUPABASE_LOCAL_SMTP_SENDER_NAME", + "local_smtp.sender_name", projectConfig.local_smtp.sender_name, projectEnvironment, + input.loadedProjectConfig, ), }) : false, @@ -354,9 +357,10 @@ export function resolveCoreStackConfig(input: { port: studioPort, apiUrl: environmentOverride( - "SUPABASE_STUDIO_API_URL", + "studio.api_url", projectConfig.studio.api_url, projectEnvironment, + input.loadedProjectConfig, ) ?? projectConfig.studio.api_url, }) : 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 index bd285e97ac..9d0bec41fb 100644 --- a/apps/cli/src/next/config/data-plane-stack-config-values.ts +++ b/apps/cli/src/next/config/data-plane-stack-config-values.ts @@ -1,5 +1,11 @@ -import type { ProjectEnvironment } from "@supabase/config"; +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; @@ -19,19 +25,13 @@ export function invalidDataPlaneConfig( } export function environmentOverride( - name: string, + path: string, configured: string | undefined, environment: ProjectEnvironment | null, + loaded: LoadedProjectConfig | null, ): string | undefined { - const override = environment?.values[name]; - const value = override === undefined || override.length === 0 ? configured : override; - if (value === undefined) return undefined; - - 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; + 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. */ @@ -43,30 +43,15 @@ export function rawEnvironmentOverride( return environment?.values[name] ?? fallback; } -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 resolveBooleanOverride(input: { + readonly loaded: LoadedProjectConfig | null; readonly environment: ProjectEnvironment | null; - readonly envName: string; readonly configured: boolean; readonly path: string; }): boolean { - const override = environmentOverride(input.envName, undefined, input.environment); + const override = effectiveEnvironmentOverride(input); if (override === undefined) return input.configured; - const value = GO_BOOLEAN_VALUES[override]; + const value = parseGoBoolean(override); if (value === undefined) { throw invalidDataPlaneConfig( input.path, @@ -76,52 +61,34 @@ export function resolveBooleanOverride(input: { return value; } -function parseBaseZeroUint(value: string): bigint | 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 { - return BigInt(literal); - } catch { - return undefined; - } -} - export function resolveUintOverride(input: { + readonly loaded: LoadedProjectConfig | null; readonly environment: ProjectEnvironment | null; - readonly envName: string; readonly configured: number; readonly path: string; }): number { - const override = environmentOverride(input.envName, undefined, input.environment); + const override = effectiveEnvironmentOverride(input); if (override === undefined) return input.configured; - const parsed = parseBaseZeroUint(override); - if (parsed === undefined || parsed > 4_294_967_295n) { + const parsed = parseGoUint32(override); + if (parsed === undefined) { throw invalidDataPlaneConfig(input.path, "Use a non-negative 32-bit integer."); } - return Number(parsed); + return parsed; } export function resolveEnumOverride(input: { + readonly loaded: LoadedProjectConfig | null; readonly environment: ProjectEnvironment | null; - readonly envName: string; readonly configured: string; readonly path: string; readonly values: ReadonlyArray; }): Value { - const resolved = environmentOverride(input.envName, input.configured, input.environment); + 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(", ")}.`); diff --git a/apps/cli/src/next/config/data-plane-stack-config.ts b/apps/cli/src/next/config/data-plane-stack-config.ts index df46bfbab1..32b85d7f32 100644 --- a/apps/cli/src/next/config/data-plane-stack-config.ts +++ b/apps/cli/src/next/config/data-plane-stack-config.ts @@ -1,4 +1,4 @@ -import type { ProjectConfig, ProjectEnvironment } from "@supabase/config"; +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"; @@ -7,6 +7,7 @@ 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; @@ -16,27 +17,32 @@ export function resolveDataPlaneStackConfig(input: { ...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 index 64a58282ea..435b93c3c5 100644 --- 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 @@ -1,4 +1,8 @@ -import { ProjectConfigSchema, type ProjectEnvironment } from "@supabase/config"; +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"; @@ -47,6 +51,7 @@ describe("resolveDataPlaneStackConfig", () => { }); const resolved = resolveDataPlaneStackConfig({ + loadedProjectConfig: null, projectConfig, projectEnvironment: environment({ SUPABASE_REALTIME_IP_VERSION: "IPv6", @@ -97,6 +102,7 @@ describe("resolveDataPlaneStackConfig", () => { 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", @@ -113,6 +119,7 @@ describe("resolveDataPlaneStackConfig", () => { const privateValue = "private-invalid-transport"; expect(() => resolveDataPlaneStackConfig({ + loadedProjectConfig: null, projectConfig, projectEnvironment: environment({ SUPABASE_REALTIME_IP_VERSION: privateValue }), configDir: "/project/supabase", @@ -121,6 +128,7 @@ describe("resolveDataPlaneStackConfig", () => { ).toThrowError(expect.objectContaining({ paths: ["realtime.ip_version"] })); try { resolveDataPlaneStackConfig({ + loadedProjectConfig: null, projectConfig, projectEnvironment: environment({ SUPABASE_REALTIME_IP_VERSION: privateValue }), configDir: "/project/supabase", @@ -131,6 +139,84 @@ describe("resolveDataPlaneStackConfig", () => { } }); + 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({ @@ -149,6 +235,7 @@ describe("resolveDataPlaneStackConfig", () => { for (const scenario of scenarios) { try { resolveDataPlaneStackConfig({ + loadedProjectConfig: null, projectConfig, projectEnvironment: environment(scenario.values), configDir: "/project/supabase", diff --git a/apps/cli/src/next/config/local-stack-config-parity.ts b/apps/cli/src/next/config/local-stack-config-parity.ts index aa883c4dd1..16b787cb84 100644 --- a/apps/cli/src/next/config/local-stack-config-parity.ts +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -33,10 +33,18 @@ export type LocalStackConfigParityDecision = }; export interface LocalStackConfigParitySection { - readonly [field: string]: LocalStackConfigParityDecision | LocalStackConfigParitySection; + readonly [field: string]: Node; } -type Node = LocalStackConfigParityDecision | LocalStackConfigParitySection; +interface LocalStackConfigParityBranch { + readonly decision: LocalStackConfigParityDecision; + readonly children: LocalStackConfigParitySection; +} + +type Node = + | LocalStackConfigParityDecision + | LocalStackConfigParityBranch + | LocalStackConfigParitySection; const unsupportedRuntimeField: LocalStackConfigParityDecision = { _tag: "unsupported-blocking", @@ -184,6 +192,20 @@ const unsupportedFutureRuntimeField: LocalStackConfigParityDecision = { "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: mappedAuthRuntimeField, client_id: mappedAuthRuntimeField, @@ -493,7 +515,7 @@ const localStackConfigParity = { edge_runtime: { enabled: mappedCoreTopologyField, policy: mappedCoreTopologyField, - inspector_port: mappedCoreTopologyField, + inspector_port: ordinaryStartInspectorField, deno_version: unsupportedRuntimeField, secrets: mappedStartFunctionsEnvironment, } satisfies Record, @@ -521,11 +543,14 @@ const localStackConfigParity = { } satisfies Record, Node>, buckets: { "*": { - public: unsupportedRuntimeField, - file_size_limit: unsupportedRuntimeField, - allowed_mime_types: unsupportedRuntimeField, - objects_path: unsupportedRuntimeField, - } satisfies Record[string], Node>, + 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, @@ -580,6 +605,10 @@ 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, @@ -587,8 +616,13 @@ export function flattenLocalStackConfigParity( ): ReadonlyArray { return Object.entries(section).flatMap(([field, node]) => { const path = prefix === "" ? field : `${prefix}.${field}`; - return isDecision(node) - ? [{ path, decision: node }] - : flattenLocalStackConfigParity(node, path); + if (isDecision(node)) return [{ path, decision: node }]; + if (isBranch(node)) { + return [ + { path, decision: node.decision }, + ...flattenLocalStackConfigParity(node.children, path), + ]; + } + return flattenLocalStackConfigParity(node, path); }); } 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 index 4e524a8e5e..20f217d2c5 100644 --- 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 @@ -7,7 +7,7 @@ describe("localStackConfigParity", () => { it("classifies every fixed project-config leaf exactly once", () => { const paths = entries.map(({ path }) => path); - expect(paths).toHaveLength(361); + expect(paths).toHaveLength(362); expect(new Set(paths).size).toBe(paths.length); expect( Object.fromEntries( @@ -17,9 +17,9 @@ describe("localStackConfigParity", () => { ]), ), ).toEqual({ - mapped: 256, - "not-applicable": 10, - "unsupported-blocking": 89, + mapped: 255, + "not-applicable": 11, + "unsupported-blocking": 90, "unsupported-warning": 6, }); }); @@ -53,7 +53,6 @@ describe("localStackConfigParity", () => { "db.seed.enabled", "db.seed.sql_paths", "edge_runtime.enabled", - "edge_runtime.inspector_port", "edge_runtime.policy", "edge_runtime.secrets", "functions.*.enabled", @@ -125,23 +124,27 @@ describe("localStackConfigParity", () => { .filter(({ decision }) => decision._tag === "not-applicable") .map(({ path }) => path) .sort(), - ).toEqual([ - "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", - ]); + ).toEqual( + [ + "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", + ].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", 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 index ffc6dfd2db..bd1c6d567a 100644 --- a/apps/cli/src/next/config/pooler-stack-config.ts +++ b/apps/cli/src/next/config/pooler-stack-config.ts @@ -1,28 +1,29 @@ -import type { ProjectConfig, ProjectEnvironment } from "@supabase/config"; +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, - envName: "SUPABASE_DB_POOLER_POOL_MODE", configured: input.config.pool_mode, path: "db.pooler.pool_mode", values: ["transaction", "session"], }); const defaultPoolSize = resolveUintOverride({ + loaded: input.loaded, environment: input.environment, - envName: "SUPABASE_DB_POOLER_DEFAULT_POOL_SIZE", configured: input.config.default_pool_size, path: "db.pooler.default_pool_size", }); const maxClientConn = resolveUintOverride({ + loaded: input.loaded, environment: input.environment, - envName: "SUPABASE_DB_POOLER_MAX_CLIENT_CONN", configured: input.config.max_client_conn, path: "db.pooler.max_client_conn", }); diff --git a/apps/cli/src/next/config/realtime-stack-config.ts b/apps/cli/src/next/config/realtime-stack-config.ts index fbd8bf0d8b..dda52abc6c 100644 --- a/apps/cli/src/next/config/realtime-stack-config.ts +++ b/apps/cli/src/next/config/realtime-stack-config.ts @@ -1,22 +1,23 @@ -import type { ProjectConfig, ProjectEnvironment } from "@supabase/config"; +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, - envName: "SUPABASE_REALTIME_IP_VERSION", configured: input.config.ip_version, path: "realtime.ip_version", values: ["IPv4", "IPv6"], }); const maxHeaderLength = resolveUintOverride({ + loaded: input.loaded, environment: input.environment, - envName: "SUPABASE_REALTIME_MAX_HEADER_LENGTH", configured: input.config.max_header_length, path: "realtime.max_header_length", }); diff --git a/apps/cli/src/next/config/stack-config.ts b/apps/cli/src/next/config/stack-config.ts index 2bbed0b869..31fada5836 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -29,6 +29,11 @@ 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; @@ -125,23 +130,35 @@ export interface ExplicitLocalStackConfigEntry { export function explicitLocalStackConfigEntries(input: { readonly projectConfig: ProjectConfig; readonly rawDocument?: Readonly>; + readonly loadedProjectConfig?: LoadedProjectConfig | null; + readonly projectEnvironment?: ProjectEnvironment | null; }): ReadonlyArray { return flattenLocalStackConfigParity().flatMap(({ path, decision }) => { const source = decision.presence === "raw-document" ? input.rawDocument : input.projectConfig; - if (source === undefined) { - return []; - } - return expandPresentValues(source, path.split(".")) - .filter(({ value }) => - decision.presence === "raw-document" ? true : hasMeaningfulDecodedValue(value), - ) - .map(({ path: explicitPath }) => ({ path: explicitPath, decision })); + const configuredEntries = + source === undefined + ? [] + : expandPresentValues(source, path.split(".")) + .filter(({ value }) => + decision.presence === "raw-document" ? true : hasMeaningfulDecodedValue(value), + ) + .map(({ path: explicitPath }) => ({ path: explicitPath, decision })); + if (configuredEntries.length > 0 || path.includes("*")) return configuredEntries; + return hasEffectiveEnvironmentOverride({ + loaded: input.loadedProjectConfig ?? null, + environment: input.projectEnvironment ?? null, + path, + }) + ? [{ path, decision }] + : []; }); } function diagnosticsFor(input: { readonly projectConfig: ProjectConfig; readonly rawDocument?: Readonly>; + readonly loadedProjectConfig: LoadedProjectConfig | null; + readonly projectEnvironment: ProjectEnvironment | null; }): { readonly warnings: ReadonlyArray; readonly blockingPaths: ReadonlyArray; @@ -307,12 +324,29 @@ export const resolveLocalStackLaunch = Effect.fnUntraced(function* (input: Local 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( - projectConfig.api.auto_expose_new_tables, + 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( @@ -333,6 +367,7 @@ export const resolveLocalStackLaunch = Effect.fnUntraced(function* (input: Local const coreConfig = yield* Effect.try({ try: () => resolveCoreStackConfig({ + loadedProjectConfig: input.loadedProjectConfig, projectConfig, rawDocument: input.loadedProjectConfig?.document, projectEnvironment: input.projectEnvironment, @@ -390,6 +425,7 @@ export const resolveLocalStackLaunch = Effect.fnUntraced(function* (input: Local const dataPlaneConfig = yield* Effect.try({ try: () => resolveDataPlaneStackConfig({ + loadedProjectConfig: input.loadedProjectConfig, projectConfig, projectEnvironment: input.projectEnvironment, configDir: 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 09849db525..c4a5f1b6c4 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -1,4 +1,8 @@ -import { ProjectConfigSchema, type LoadedProjectConfig } from "@supabase/config"; +import { + ProjectConfigSchema, + type LoadedProjectConfig, + type ProjectEnvironment, +} from "@supabase/config"; import { BunServices } from "@effect/platform-bun"; import { Effect, Schema } from "effect"; import { describe, expect, it } from "vitest"; @@ -16,16 +20,39 @@ const decodeProjectConfig = Schema.decodeUnknownSync(ProjectConfigSchema); const resolveLocalStackLaunchWithBun = (input: Parameters[0]) => resolveLocalStackLaunch(input).pipe(Effect.provide(BunServices.layer)); -function loaded(document: Record): LoadedProjectConfig { +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, @@ -172,6 +199,91 @@ describe("resolveLocalStackLaunch", () => { 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({ @@ -211,6 +323,26 @@ describe("resolveLocalStackLaunch", () => { }), }), ); + 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() }), @@ -218,6 +350,11 @@ describe("resolveLocalStackLaunch", () => { 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 () => { @@ -317,6 +454,35 @@ describe("resolveLocalStackLaunch", () => { 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({ @@ -336,4 +502,27 @@ describe("resolveLocalStackLaunch", () => { ]); 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 index 83215e1cf5..96a5a2ff41 100644 --- a/apps/cli/src/next/config/storage-stack-config.ts +++ b/apps/cli/src/next/config/storage-stack-config.ts @@ -1,5 +1,6 @@ import { parseStorageSizeBytes, + type LoadedProjectConfig, type ProjectConfig, type ProjectEnvironment, } from "@supabase/config"; @@ -12,12 +13,17 @@ import { } 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("SUPABASE_STORAGE_FILE_SIZE_LIMIT", input.configured, input.environment) ?? - input.configured; + environmentOverride( + "storage.file_size_limit", + input.configured, + input.environment, + input.loaded, + ) ?? input.configured; try { return String(parseStorageSizeBytes(configured)); } catch { @@ -29,23 +35,25 @@ function resolveFileSizeLimit(input: { } 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, - envName: "SUPABASE_STORAGE_S3_PROTOCOL_ENABLED", configured: input.config.s3_protocol.enabled, path: "storage.s3_protocol.enabled", }); const vectorBucketsEnabled = resolveBooleanOverride({ + loaded: input.loaded, environment: input.environment, - envName: "SUPABASE_STORAGE_VECTOR_ENABLED", configured: input.config.vector.enabled, path: "storage.vector.enabled", }); diff --git a/apps/cli/src/next/config/studio-stack-config.ts b/apps/cli/src/next/config/studio-stack-config.ts index df97544d9f..3ad3d443d0 100644 --- a/apps/cli/src/next/config/studio-stack-config.ts +++ b/apps/cli/src/next/config/studio-stack-config.ts @@ -1,16 +1,18 @@ -import type { ProjectConfig, ProjectEnvironment } from "@supabase/config"; +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( - "SUPABASE_STUDIO_OPENAI_API_KEY", + "studio.openai_api_key", input.config.openai_api_key, input.environment, + input.loaded, ); return input.base === false ? false : { ...input.base, openAiApiKey }; } 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 a09b321502..1880378930 100644 --- a/packages/config/src/io.unit.test.ts +++ b/packages/config/src/io.unit.test.ts @@ -1416,6 +1416,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/stack/src/Stack.unit.test.ts b/packages/stack/src/Stack.unit.test.ts index feecf3c14b..b47c1aed6d 100644 --- a/packages/stack/src/Stack.unit.test.ts +++ b/packages/stack/src/Stack.unit.test.ts @@ -733,6 +733,43 @@ describe("Stack", () => { }).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, diff --git a/packages/stack/src/StackBuilder.ts b/packages/stack/src/StackBuilder.ts index f8b5239022..6e09336a9a 100644 --- a/packages/stack/src/StackBuilder.ts +++ b/packages/stack/src/StackBuilder.ts @@ -18,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"; @@ -232,7 +235,8 @@ export class StackBuilder extends Context.Service< postgresResolution, dockerServicesEnabled, ); - const hasPostgresInit = postgresResolution.type === "binary"; + const hasPostgresInit = + postgresResolution.type === "binary" || !config.postgres.autoExposeNewTables; const initialPostgresDeps = dependsOnPostgres(hasPostgresInit); const bootstrapRuntime: DatabaseBootstrapRuntime = postgresResolution.type === "binary" @@ -275,7 +279,7 @@ export class StackBuilder extends Context.Service< }, ]; - if (hasPostgresInit) { + if (postgresResolution.type === "binary") { defs.push({ ...makePostgresInitService({ postgresDir: postgresResolution.path, @@ -285,6 +289,15 @@ 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) { @@ -572,8 +585,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, diff --git a/packages/stack/src/StackBuilder.unit.test.ts b/packages/stack/src/StackBuilder.unit.test.ts index 65c5d0aea9..097de91f07 100644 --- a/packages/stack/src/StackBuilder.unit.test.ts +++ b/packages/stack/src/StackBuilder.unit.test.ts @@ -336,6 +336,82 @@ describe("StackBuilder", () => { }, ); + 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); @@ -516,6 +592,39 @@ describe("StackBuilder", () => { }).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); 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/services.unit.test.ts b/packages/stack/src/services/services.unit.test.ts index 8b8cb74b8b..da7270693c 100644 --- a/packages/stack/src/services/services.unit.test.ts +++ b/packages/stack/src/services/services.unit.test.ts @@ -12,6 +12,7 @@ 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"; @@ -966,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; From 45437fe42b6a8ca2655b635a194083f2ea694931 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 18:49:07 +0200 Subject: [PATCH 19/26] fix(cli): preserve remote precedence for auth bindings --- apps/cli/src/next/config/auth-stack-config.ts | 157 +++++------ .../config/auth-stack-config.unit.test.ts | 248 +++++++++++++++++- apps/cli/src/next/config/stack-config.ts | 2 +- 3 files changed, 325 insertions(+), 82 deletions(-) diff --git a/apps/cli/src/next/config/auth-stack-config.ts b/apps/cli/src/next/config/auth-stack-config.ts index 0bcb377f23..b682aeb0dd 100644 --- a/apps/cli/src/next/config/auth-stack-config.ts +++ b/apps/cli/src/next/config/auth-stack-config.ts @@ -1,4 +1,4 @@ -import type { ProjectConfig, ProjectEnvironment } from "@supabase/config"; +import type { LoadedProjectConfig, ProjectConfig, ProjectEnvironment } from "@supabase/config"; import { defaultJwtSecret, type AuthConfig, @@ -14,6 +14,12 @@ import { 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; @@ -64,21 +70,6 @@ function isRecord(value: unknown): value is Readonly> { return typeof value === "object" && value !== null && !Array.isArray(value); } -function environmentOverride( - environment: ProjectEnvironment | null, - name: string, - configured: string | undefined, -): string | undefined { - const value = environment?.values[name]; - if (value === undefined || value.length === 0) return configured; - 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 invalidOverride(path: string, suggestion: string): AuthStackConfigError { return new AuthStackConfigError({ path, @@ -87,32 +78,17 @@ function invalidOverride(path: string, suggestion: string): AuthStackConfigError }); } -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, -}; - function envBoolean(input: { + readonly loaded: LoadedProjectConfig | null; readonly environment: ProjectEnvironment | null; - readonly name: string; readonly configured: boolean; readonly path: string; readonly enabled?: boolean; }): boolean { if (input.enabled === false) return input.configured; - const value = environmentOverride(input.environment, input.name, undefined); + const value = effectiveEnvironmentOverride(input); if (value === undefined) return input.configured; - const parsed = GO_BOOLEAN_VALUES[value]; + const parsed = parseGoBoolean(value); if (parsed === undefined) { throw invalidOverride(input.path, "Use a Go-compatible boolean such as true, false, 1, or 0."); } @@ -142,15 +118,15 @@ function parseGoUnsigned(value: string): number | undefined { } function envNumber(input: { + readonly loaded: LoadedProjectConfig | null; readonly environment: ProjectEnvironment | null; - readonly name: string; readonly configured: number | undefined; readonly path: string; readonly max?: number; readonly enabled?: boolean; }): number | undefined { if (input.enabled === false) return input.configured; - const value = environmentOverride(input.environment, input.name, undefined); + const value = effectiveEnvironmentOverride(input); if (value === undefined) return input.configured; const parsed = parseGoUnsigned(value); if (parsed === undefined || (input.max !== undefined && parsed > input.max)) { @@ -160,23 +136,37 @@ function envNumber(input: { } function envString(input: { + readonly loaded: LoadedProjectConfig | null; readonly environment: ProjectEnvironment | null; - readonly name: string; + readonly path: string; readonly configured: string | undefined; readonly enabled?: boolean; }): string | undefined { - return input.enabled === false - ? input.configured - : environmentOverride(input.environment, input.name, input.configured); + 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 name: string; + readonly path: string; readonly configured: ReadonlyArray; }): ReadonlyArray { - const value = environmentOverride(input.environment, input.name, undefined); - return value === undefined ? input.configured : value.length === 0 ? [] : value.split(","); + 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 { @@ -198,14 +188,15 @@ function resolvePasswordRequirements(value: string): PasswordRequirements { 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, - name: `SUPABASE_AUTH_SMS_${name.toUpperCase()}_ENABLED`, configured, path: `auth.sms.${name}.enabled`, enabled: providerPresent(name), @@ -216,8 +207,9 @@ function resolveSmsProvider(input: { configured: string | undefined, ): string | undefined => envString({ + loaded: input.loaded, environment: input.environment, - name: `SUPABASE_AUTH_SMS_${name.toUpperCase()}_${field.toUpperCase()}`, + path: `auth.sms.${name}.${field}`, configured, enabled: providerPresent(name), }); @@ -303,6 +295,7 @@ function resolveSmsProvider(input: { 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) @@ -311,18 +304,18 @@ function resolveExternalProviders(input: { return Object.fromEntries( Object.entries(input.external).map(([name, provider]) => { const sectionPresent = name === "apple" || isRecord(externalDocument?.[name]); - const prefix = `SUPABASE_AUTH_EXTERNAL_${name.toUpperCase()}`; const stringField = (field: string, configured: string | undefined) => envString({ + loaded: input.loaded, environment: input.environment, - name: `${prefix}_${field.toUpperCase()}`, + path: `auth.external.${name}.${field}`, configured, enabled: sectionPresent, }); const booleanField = (field: string, configured: boolean) => envBoolean({ + loaded: input.loaded, environment: input.environment, - name: `${prefix}_${field.toUpperCase()}`, configured, path: `auth.external.${name}.${field}`, enabled: sectionPresent, @@ -346,32 +339,34 @@ function resolveExternalProviders(input: { 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]); - const prefix = `SUPABASE_AUTH_HOOK_${name.toUpperCase()}`; return [ name, { enabled: envBoolean({ + loaded: input.loaded, environment: input.environment, - name: `${prefix}_ENABLED`, configured: hook.enabled, path: `auth.hook.${name}.enabled`, enabled: sectionPresent, }), uri: envString({ + loaded: input.loaded, environment: input.environment, - name: `${prefix}_URI`, + path: `auth.hook.${name}.uri`, configured: hook.uri, enabled: sectionPresent, }), secrets: envString({ + loaded: input.loaded, environment: input.environment, - name: `${prefix}_SECRETS`, + path: `auth.hook.${name}.secrets`, configured: hook.secrets, enabled: sectionPresent, }), @@ -423,31 +418,34 @@ interface TranslatedAuthStackConfig { export const translateAuthStackConfig = Effect.fnUntraced(function* (input: { readonly projectConfig: ProjectConfig; - readonly rawDocument?: Readonly>; + readonly loadedProjectConfig: LoadedProjectConfig | null; readonly projectEnvironment: ProjectEnvironment | null; readonly configDir: string; readonly authEnabled: boolean; }) { const { auth } = input.projectConfig; - const authDocument = isRecord(input.rawDocument?.auth) ? input.rawDocument.auth : undefined; + 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, - name: `SUPABASE_AUTH_${field.toUpperCase()}`, + path: `auth.${field}`, configured, }); const flatBoolean = (field: string, configured: boolean) => envBoolean({ + loaded: input.loadedProjectConfig, environment: input.projectEnvironment, - name: `SUPABASE_AUTH_${field.toUpperCase()}`, configured, path: `auth.${field}`, }); const flatNumber = (field: string, configured: number) => envNumber({ + loaded: input.loadedProjectConfig, environment: input.projectEnvironment, - name: `SUPABASE_AUTH_${field.toUpperCase()}`, configured, path: `auth.${field}`, }) ?? configured; @@ -483,15 +481,16 @@ export const translateAuthStackConfig = Effect.fnUntraced(function* (input: { const smtpEnabled = smtpPresent && envBoolean({ + loaded: input.loadedProjectConfig, environment: input.projectEnvironment, - name: "SUPABASE_AUTH_EMAIL_SMTP_ENABLED", 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, - name: `SUPABASE_AUTH_EMAIL_SMTP_${field.toUpperCase()}`, + path: `auth.email.smtp.${field}`, configured, enabled: smtpPresent, }); @@ -500,8 +499,8 @@ export const translateAuthStackConfig = Effect.fnUntraced(function* (input: { host: required(smtpString("host", auth.email.smtp?.host), "auth.email.smtp.host"), port: requiredNumber( envNumber({ + loaded: input.loadedProjectConfig, environment: input.projectEnvironment, - name: "SUPABASE_AUTH_EMAIL_SMTP_PORT", configured: auth.email.smtp?.port, path: "auth.email.smtp.port", max: 65_535, @@ -524,8 +523,9 @@ export const translateAuthStackConfig = Effect.fnUntraced(function* (input: { auth: { siteUrl: flatString("site_url", auth.site_url) ?? auth.site_url, additionalRedirectUrls: envList({ + loaded: input.loadedProjectConfig, environment: input.projectEnvironment, - name: "SUPABASE_AUTH_ADDITIONAL_REDIRECT_URLS", + path: "auth.additional_redirect_urls", configured: auth.additional_redirect_urls, }), jwtExpiry: flatNumber("jwt_expiry", auth.jwt_expiry), @@ -551,46 +551,47 @@ export const translateAuthStackConfig = Effect.fnUntraced(function* (input: { ), email: { enableSignup: envBoolean({ + loaded: input.loadedProjectConfig, environment: input.projectEnvironment, - name: "SUPABASE_AUTH_EMAIL_ENABLE_SIGNUP", configured: auth.email.enable_signup, path: "auth.email.enable_signup", }), doubleConfirmChanges: envBoolean({ + loaded: input.loadedProjectConfig, environment: input.projectEnvironment, - name: "SUPABASE_AUTH_EMAIL_DOUBLE_CONFIRM_CHANGES", configured: auth.email.double_confirm_changes, path: "auth.email.double_confirm_changes", }), enableConfirmations: envBoolean({ + loaded: input.loadedProjectConfig, environment: input.projectEnvironment, - name: "SUPABASE_AUTH_EMAIL_ENABLE_CONFIRMATIONS", configured: auth.email.enable_confirmations, path: "auth.email.enable_confirmations", }), securePasswordChange: envBoolean({ + loaded: input.loadedProjectConfig, environment: input.projectEnvironment, - name: "SUPABASE_AUTH_EMAIL_SECURE_PASSWORD_CHANGE", configured: auth.email.secure_password_change, path: "auth.email.secure_password_change", }), maxFrequency: envString({ + loaded: input.loadedProjectConfig, environment: input.projectEnvironment, - name: "SUPABASE_AUTH_EMAIL_MAX_FREQUENCY", + path: "auth.email.max_frequency", configured: auth.email.max_frequency, }) ?? auth.email.max_frequency, otpLength: envNumber({ + loaded: input.loadedProjectConfig, environment: input.projectEnvironment, - name: "SUPABASE_AUTH_EMAIL_OTP_LENGTH", configured: auth.email.otp_length, path: "auth.email.otp_length", }) ?? auth.email.otp_length, otpExpiry: envNumber({ + loaded: input.loadedProjectConfig, environment: input.projectEnvironment, - name: "SUPABASE_AUTH_EMAIL_OTP_EXPIRY", configured: auth.email.otp_expiry, path: "auth.email.otp_expiry", }) ?? auth.email.otp_expiry, @@ -598,44 +599,54 @@ export const translateAuthStackConfig = Effect.fnUntraced(function* (input: { }, sms: { enableSignup: envBoolean({ + loaded: input.loadedProjectConfig, environment: input.projectEnvironment, - name: "SUPABASE_AUTH_SMS_ENABLE_SIGNUP", configured: auth.sms.enable_signup, path: "auth.sms.enable_signup", }), enableConfirmations: envBoolean({ + loaded: input.loadedProjectConfig, environment: input.projectEnvironment, - name: "SUPABASE_AUTH_SMS_ENABLE_CONFIRMATIONS", configured: auth.sms.enable_confirmations, path: "auth.sms.enable_confirmations", }), template: envString({ + loaded: input.loadedProjectConfig, environment: input.projectEnvironment, - name: "SUPABASE_AUTH_SMS_TEMPLATE", + path: "auth.sms.template", configured: auth.sms.template, }) ?? auth.sms.template, maxFrequency: envString({ + loaded: input.loadedProjectConfig, environment: input.projectEnvironment, - name: "SUPABASE_AUTH_SMS_MAX_FREQUENCY", + path: "auth.sms.max_frequency", configured: auth.sms.max_frequency, }) ?? auth.sms.max_frequency, - testOtp: auth.sms.test_otp, + 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, }), }, 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 index 41e8645cb0..a4a6adec09 100644 --- a/apps/cli/src/next/config/auth-stack-config.unit.test.ts +++ b/apps/cli/src/next/config/auth-stack-config.unit.test.ts @@ -1,4 +1,4 @@ -import { ProjectConfigSchema } from "@supabase/config"; +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"; @@ -23,10 +23,35 @@ function projectEnvironment(values: Readonly>) { }; } +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( - translateAuthStackConfig({ + translateAuth({ configDir: "/project/supabase", authEnabled: true, projectEnvironment: null, @@ -138,7 +163,7 @@ describe("translateAuthStackConfig", () => { ]), ); const result = await Effect.runPromise( - translateAuthStackConfig({ + translateAuth({ configDir, authEnabled: true, projectEnvironment: projectEnvironment({ @@ -169,7 +194,7 @@ describe("translateAuthStackConfig", () => { ]), ); const exit = await Effect.runPromise( - translateAuthStackConfig({ + translateAuth({ configDir, authEnabled: true, projectEnvironment: null, @@ -187,7 +212,7 @@ describe("translateAuthStackConfig", () => { it("applies typed Auth environment overrides without retaining secret values", async () => { const result = await Effect.runPromise( - translateAuthStackConfig({ + translateAuth({ configDir: "/project/supabase", authEnabled: true, rawDocument: { auth: { external: { github: {} } } }, @@ -224,9 +249,216 @@ describe("translateAuthStackConfig", () => { }); }); + 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( - translateAuthStackConfig({ + translateAuth({ configDir: "/project/supabase", authEnabled: true, projectEnvironment: projectEnvironment({ @@ -242,7 +474,7 @@ describe("translateAuthStackConfig", () => { it("applies env-only overrides only for sections registered by the legacy defaults", async () => { const result = await Effect.runPromise( - translateAuthStackConfig({ + translateAuth({ configDir: "/project/supabase", authEnabled: true, projectEnvironment: projectEnvironment({ @@ -266,7 +498,7 @@ describe("translateAuthStackConfig", () => { it("validates signing keys even when Auth is excluded", async () => { await expect( Effect.runPromise( - translateAuthStackConfig({ + translateAuth({ configDir: "/missing", authEnabled: false, projectEnvironment: null, diff --git a/apps/cli/src/next/config/stack-config.ts b/apps/cli/src/next/config/stack-config.ts index 31fada5836..03b276fd0a 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -389,7 +389,7 @@ export const resolveLocalStackLaunch = Effect.fnUntraced(function* (input: Local : dirname(input.loadedProjectConfig.path); const translatedAuth = yield* translateAuthStackConfig({ projectConfig, - rawDocument: input.loadedProjectConfig?.document, + loadedProjectConfig: input.loadedProjectConfig, projectEnvironment: input.projectEnvironment, configDir, authEnabled: coreConfig.auth !== false, From 7d9b68dbc11ec535d0dd9dfcc21d14cf6d73b139 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 19:04:45 +0200 Subject: [PATCH 20/26] chore(tooling): remove obsolete knip exceptions --- packages/process-compose/package.json | 3 +-- packages/process-compose/src/RestartDecision.ts | 3 --- packages/stack/package.json | 3 +-- 3 files changed, 2 insertions(+), 7 deletions(-) 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/process-compose/src/RestartDecision.ts b/packages/process-compose/src/RestartDecision.ts index dc1d2fc5ae..702e00712c 100644 --- a/packages/process-compose/src/RestartDecision.ts +++ b/packages/process-compose/src/RestartDecision.ts @@ -13,9 +13,6 @@ export type RestartDecision = } | { readonly _tag: "KeepRunningUnhealthy" }; -export const UNHEALTHY_RESTART_EXHAUSTED_ERROR = - "Health check failed and restart budget was exhausted"; - export function decideRestart(options: { readonly cause: LifecycleCause; readonly policy: RestartPolicy; 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" ] } } From 4d3f3884d78f392c0dbecc2ba299083c28054cc3 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 20:04:40 +0200 Subject: [PATCH 21/26] test(cli): refine local config presence semantics --- .../next/config/local-stack-config-parity.ts | 118 +++++++++++------- .../local-stack-config-parity.unit.test.ts | 17 ++- 2 files changed, 83 insertions(+), 52 deletions(-) diff --git a/apps/cli/src/next/config/local-stack-config-parity.ts b/apps/cli/src/next/config/local-stack-config-parity.ts index af5daceec4..e60eabd463 100644 --- a/apps/cli/src/next/config/local-stack-config-parity.ts +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -11,8 +11,10 @@ import type { ProjectConfig } from "@supabase/config"; */ type LocalStackConfigParityPresence = | "decoded-value" + | "effective-global-secret" | "effective-secret" | "enabled-subtree" + | "non-default-value" | "raw-document"; type LocalStackConfigParityDecision = @@ -73,6 +75,20 @@ const unsupportedSecretRuntimeField: LocalStackConfigParityDecision = { "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 unsupportedGlobalSecretRuntimeField: LocalStackConfigParityDecision = { + _tag: "unsupported-blocking", + presence: "effective-global-secret", + rationale: + "A concrete resolved global credential changes local runtime behavior even when the Auth service is disabled; 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 unsupportedEnabledProviderField: LocalStackConfigParityDecision = { _tag: "unsupported-blocking", presence: "enabled-subtree", @@ -203,6 +219,14 @@ const authExternalParity = { zoom: authExternalProviderParity, } satisfies AuthExternalParity; +const authExternalWithCustomParity = { + ...authExternalParity, + "*": { + decision: unsupportedEnabledProviderField, + children: authExternalProviderParity, + }, +} satisfies LocalStackConfigParitySection; + const authHooksParity = { mfa_verification_attempt: authHookParity, password_verification_attempt: authHookParity, @@ -218,31 +242,31 @@ const authSmsParity = { template: unsupportedRuntimeField, max_frequency: unsupportedRuntimeField, twilio: { - enabled: unsupportedRuntimeField, - account_sid: unsupportedRuntimeField, - message_service_sid: unsupportedRuntimeField, + enabled: unsupportedEnabledProviderField, + account_sid: unsupportedEnabledProviderField, + message_service_sid: unsupportedEnabledProviderField, auth_token: unsupportedSecretRuntimeField, } satisfies Record, twilio_verify: { - enabled: unsupportedRuntimeField, - account_sid: unsupportedOptionalRuntimeField, - message_service_sid: unsupportedOptionalRuntimeField, + enabled: unsupportedEnabledProviderField, + account_sid: unsupportedEnabledProviderField, + message_service_sid: unsupportedEnabledProviderField, auth_token: unsupportedSecretRuntimeField, } satisfies Record, messagebird: { - enabled: unsupportedRuntimeField, - originator: unsupportedOptionalRuntimeField, + enabled: unsupportedEnabledProviderField, + originator: unsupportedEnabledProviderField, access_key: unsupportedSecretRuntimeField, } satisfies Record, textlocal: { - enabled: unsupportedRuntimeField, - sender: unsupportedOptionalRuntimeField, + enabled: unsupportedEnabledProviderField, + sender: unsupportedEnabledProviderField, api_key: unsupportedSecretRuntimeField, } satisfies Record, vonage: { - enabled: unsupportedRuntimeField, - from: unsupportedOptionalRuntimeField, - api_key: unsupportedOptionalRuntimeField, + enabled: unsupportedEnabledProviderField, + from: unsupportedEnabledProviderField, + api_key: unsupportedEnabledProviderField, api_secret: unsupportedSecretRuntimeField, } satisfies Record, test_otp: unsupportedOptionalRuntimeField, @@ -262,11 +286,11 @@ const authParity = { enable_anonymous_sign_ins: unsupportedRuntimeField, minimum_password_length: unsupportedRuntimeField, password_requirements: unsupportedRuntimeField, - publishable_key: unsupportedSecretRuntimeField, - secret_key: unsupportedSecretRuntimeField, - jwt_secret: unsupportedSecretRuntimeField, - anon_key: unsupportedSecretRuntimeField, - service_role_key: unsupportedSecretRuntimeField, + publishable_key: unsupportedGlobalSecretRuntimeField, + secret_key: unsupportedGlobalSecretRuntimeField, + jwt_secret: unsupportedGlobalSecretRuntimeField, + anon_key: unsupportedGlobalSecretRuntimeField, + service_role_key: unsupportedGlobalSecretRuntimeField, rate_limit: authRateLimitParity, captcha: { enabled: unsupportedRuntimeField, @@ -334,7 +358,7 @@ const authParity = { }, } satisfies Record, sms: authSmsParity, - external: authExternalParity, + external: authExternalWithCustomParity, web3: { solana: { enabled: unsupportedRuntimeField, @@ -344,32 +368,32 @@ const authParity = { } satisfies Record, } satisfies Record, oauth_server: { - enabled: unsupportedRuntimeField, - authorization_url_path: unsupportedRuntimeField, - allow_dynamic_registration: unsupportedRuntimeField, + enabled: unsupportedEnabledProviderField, + authorization_url_path: unsupportedEnabledProviderField, + allow_dynamic_registration: unsupportedEnabledProviderField, } satisfies Record, third_party: { firebase: { - enabled: unsupportedRuntimeField, - project_id: unsupportedOptionalRuntimeField, + enabled: unsupportedEnabledProviderField, + project_id: unsupportedEnabledProviderField, } satisfies Record, auth0: { - enabled: unsupportedRuntimeField, - tenant: unsupportedOptionalRuntimeField, - tenant_region: unsupportedOptionalRuntimeField, + enabled: unsupportedEnabledProviderField, + tenant: unsupportedEnabledProviderField, + tenant_region: unsupportedEnabledProviderField, } satisfies Record, aws_cognito: { - enabled: unsupportedRuntimeField, - user_pool_id: unsupportedOptionalRuntimeField, - user_pool_region: unsupportedOptionalRuntimeField, + enabled: unsupportedEnabledProviderField, + user_pool_id: unsupportedEnabledProviderField, + user_pool_region: unsupportedEnabledProviderField, } satisfies Record, clerk: { - enabled: unsupportedRuntimeField, - domain: unsupportedOptionalRuntimeField, + enabled: unsupportedEnabledProviderField, + domain: unsupportedEnabledProviderField, } satisfies Record, workos: { - enabled: unsupportedRuntimeField, - issuer_url: unsupportedOptionalRuntimeField, + enabled: unsupportedEnabledProviderField, + issuer_url: unsupportedEnabledProviderField, } satisfies Record, } satisfies Record, } satisfies Record; @@ -429,9 +453,9 @@ const localStackConfigParity = { max_rows: unsupportedRuntimeField, auto_expose_new_tables: mappedAutoExposeNewTables, tls: { - enabled: unsupportedRuntimeField, - cert_path: unsupportedOptionalRuntimeField, - key_path: unsupportedOptionalRuntimeField, + enabled: unsupportedEnabledProviderField, + cert_path: unsupportedEnabledProviderField, + key_path: unsupportedEnabledProviderField, } satisfies Record, external_url: unsupportedOptionalRuntimeField, } satisfies Record, @@ -471,7 +495,7 @@ const localStackConfigParity = { enabled: mappedFunctionsDevEdgeRuntime, policy: mappedFunctionsDevEdgeRuntime, inspector_port: mappedFunctionsDevEdgeRuntime, - deno_version: unsupportedRuntimeField, + deno_version: unsupportedNonDefaultRuntimeField, secrets: mappedFunctionsDevEdgeRuntime, } satisfies Record, functions: { @@ -514,13 +538,13 @@ const localStackConfigParity = { enabled: unsupportedRuntimeField, } satisfies Record, analytics: { - enabled: unsupportedRuntimeField, - max_namespaces: unsupportedRuntimeField, - max_tables: unsupportedRuntimeField, - max_catalogs: unsupportedRuntimeField, + enabled: unsupportedEnabledProviderField, + max_namespaces: unsupportedEnabledProviderField, + max_tables: unsupportedEnabledProviderField, + max_catalogs: unsupportedEnabledProviderField, buckets: { "*": { - decision: unsupportedRuntimeField, + decision: unsupportedEnabledProviderField, children: {} satisfies Record< keyof ProjectConfig["storage"]["analytics"]["buckets"][string], Node @@ -529,12 +553,12 @@ const localStackConfigParity = { }, } satisfies Record, vector: { - enabled: unsupportedRuntimeField, - max_buckets: unsupportedRuntimeField, - max_indexes: unsupportedRuntimeField, + enabled: unsupportedEnabledProviderField, + max_buckets: unsupportedEnabledProviderField, + max_indexes: unsupportedEnabledProviderField, buckets: { "*": { - decision: unsupportedRuntimeField, + decision: unsupportedEnabledProviderField, children: {} satisfies Record< keyof ProjectConfig["storage"]["vector"]["buckets"][string], Node 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 index 39f79f958b..dbafe815c1 100644 --- 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 @@ -7,7 +7,7 @@ describe("localStackConfigParity", () => { it("classifies every fixed project-config leaf exactly once", () => { const paths = entries.map(({ path }) => path); - expect(paths).toHaveLength(365); + expect(paths).toHaveLength(373); expect(new Set(paths).size).toBe(paths.length); expect( Object.fromEntries( @@ -19,7 +19,7 @@ describe("localStackConfigParity", () => { ).toEqual({ mapped: 11, "not-applicable": 10, - "unsupported-blocking": 338, + "unsupported-blocking": 346, "unsupported-warning": 6, }); }); @@ -70,11 +70,18 @@ describe("localStackConfigParity", () => { 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("raw-document"); - expect(byPath.get("auth.sms.twilio.enabled")?.presence).toBe("raw-document"); + 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("effective-global-secret"); + 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("raw-document"); - expect(byPath.get("storage.vector.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"); expect(byPath.get("auth.external.apple.secret")?.presence).toBe("effective-secret"); expect(byPath.get("studio.openai_api_key")?.presence).toBe("effective-secret"); From cc50c84c0eb370614f666828a89178ed9da4529c Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 20:50:30 +0200 Subject: [PATCH 22/26] fix(cli): honor effective local config presence --- .../next/config/database-bootstrap-config.ts | 18 +- .../next/config/local-stack-config-parity.ts | 19 ++- .../local-stack-config-parity.unit.test.ts | 8 +- apps/cli/src/next/config/stack-config.ts | 157 ++++++++++++++++-- .../src/next/config/stack-config.unit.test.ts | 53 +++++- .../process-compose/src/RestartDecision.ts | 3 + 6 files changed, 221 insertions(+), 37 deletions(-) diff --git a/apps/cli/src/next/config/database-bootstrap-config.ts b/apps/cli/src/next/config/database-bootstrap-config.ts index 2176edfe16..6c0574361a 100644 --- a/apps/cli/src/next/config/database-bootstrap-config.ts +++ b/apps/cli/src/next/config/database-bootstrap-config.ts @@ -95,10 +95,6 @@ function resolveList(input: { return override === undefined ? input.configured : override.split(","); } -function rawDefines(loaded: LoadedProjectConfig, path: ReadonlyArray): boolean { - return nestedValue(loaded.document, path) !== undefined; -} - async function exists(path: string): Promise { try { await stat(path); @@ -233,12 +229,14 @@ export const translateDatabaseBootstrapConfig = Effect.fnUntraced(function* (inp return yield* Effect.tryPromise({ try: async (): Promise => { - const schemaPathsConfigured = - rawDefines(loaded, ["db", "migrations", "schema_paths"]) || - remoteDefines(loaded, ["db", "migrations", "schema_paths"]) || - environmentOverride("SUPABASE_DB_MIGRATIONS_SCHEMA_PATHS", input.projectEnvironment) !== - undefined; - if (schemaPathsConfigured) { + 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.", diff --git a/apps/cli/src/next/config/local-stack-config-parity.ts b/apps/cli/src/next/config/local-stack-config-parity.ts index f205f8e860..372eeaf2af 100644 --- a/apps/cli/src/next/config/local-stack-config-parity.ts +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -645,6 +645,8 @@ const localStackConfigParity = { 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 { @@ -659,16 +661,25 @@ function isBranch(node: Node): node is LocalStackConfigParityBranch { 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}`; - if (isDecision(node)) return [{ path, decision: node }]; + 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 }, - ...flattenLocalStackConfigParity(node.children, path), + { path, decision: node.decision, wildcardExclusions }, + ...flattenLocalStackConfigParity(node.children, path, wildcardExclusions), ]; } - return flattenLocalStackConfigParity(node, path); + 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 index cbd3d916f8..de742bf5ff 100644 --- 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 @@ -7,7 +7,7 @@ describe("localStackConfigParity", () => { it("classifies every fixed project-config leaf exactly once", () => { const paths = entries.map(({ path }) => path); - expect(paths).toHaveLength(373); + expect(paths).toHaveLength(370); expect(new Set(paths).size).toBe(paths.length); expect( Object.fromEntries( @@ -17,9 +17,9 @@ describe("localStackConfigParity", () => { ]), ), ).toEqual({ - mapped: 255, + mapped: 262, "not-applicable": 11, - "unsupported-blocking": 101, + "unsupported-blocking": 91, "unsupported-warning": 6, }); }); @@ -80,7 +80,7 @@ describe("localStackConfigParity", () => { "studio.openai_api_key", "studio.port", ]); - expect(mappedPaths.filter((path) => path.startsWith("auth."))).toHaveLength(206); + expect(mappedPaths.filter((path) => path.startsWith("auth."))).toHaveLength(213); expect(mappedPaths).toEqual( expect.arrayContaining([ "auth.enabled", diff --git a/apps/cli/src/next/config/stack-config.ts b/apps/cli/src/next/config/stack-config.ts index 03b276fd0a..f3cda3e061 100644 --- a/apps/cli/src/next/config/stack-config.ts +++ b/apps/cli/src/next/config/stack-config.ts @@ -87,6 +87,8 @@ function expandPresentValues( root: unknown, segments: ReadonlyArray, prefix = "", + wildcardExclusions: Readonly>> = {}, + segmentIndex = 0, ): ReadonlyArray { const [segment, ...rest] = segments; if (segment === undefined) { @@ -97,15 +99,30 @@ function expandPresentValues( } if (segment === "*") { + const excluded = new Set(wildcardExclusions[segmentIndex] ?? []); return Object.entries(root).flatMap(([key, value]) => - expandPresentValues(value, rest, prefix === "" ? key : `${prefix}.${key}`), + 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}`); + return expandPresentValues( + root[segment], + rest, + prefix === "" ? segment : `${prefix}.${segment}`, + wildcardExclusions, + segmentIndex + 1, + ); } function hasMeaningfulDecodedValue(value: unknown): boolean { @@ -121,6 +138,83 @@ function hasMeaningfulDecodedValue(value: unknown): boolean { 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; @@ -133,24 +227,53 @@ export function explicitLocalStackConfigEntries(input: { readonly loadedProjectConfig?: LoadedProjectConfig | null; readonly projectEnvironment?: ProjectEnvironment | null; }): ReadonlyArray { - return flattenLocalStackConfigParity().flatMap(({ path, decision }) => { + return flattenLocalStackConfigParity().flatMap(({ path, decision, wildcardExclusions }) => { const source = decision.presence === "raw-document" ? input.rawDocument : input.projectConfig; - const configuredEntries = + const expanded = source === undefined ? [] - : expandPresentValues(source, path.split(".")) - .filter(({ value }) => - decision.presence === "raw-document" ? true : hasMeaningfulDecodedValue(value), - ) - .map(({ path: explicitPath }) => ({ path: explicitPath, decision })); - if (configuredEntries.length > 0 || path.includes("*")) return configuredEntries; - return hasEffectiveEnvironmentOverride({ - loaded: input.loadedProjectConfig ?? null, - environment: input.projectEnvironment ?? null, - path, - }) - ? [{ path, decision }] - : []; + : 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 }]; + }); }); } 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 c4a5f1b6c4..f949988e64 100644 --- a/apps/cli/src/next/config/stack-config.unit.test.ts +++ b/apps/cli/src/next/config/stack-config.unit.test.ts @@ -5,7 +5,9 @@ import { } 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 { renderProjectConfigTemplate } from "../../shared/init/project-init.templates.ts"; import { AUTO_EXPOSE_NEW_TABLES_DEPRECATION_WARNING, baseStackConfig, @@ -138,9 +140,56 @@ describe("explicitLocalStackConfigEntries", () => { 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( + 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), + }), + ); + + expect(result.stackConfig).toBeDefined(); + }); + it("maps API and database topology into the stack interface", async () => { const result = await Effect.runPromise( resolveLocalStackLaunchWithBun({ @@ -436,8 +485,8 @@ describe("resolveLocalStackLaunch", () => { resolveLocalStackLaunchWithBun({ ...baseLaunchInput, loadedProjectConfig: loaded({ - auth: { captcha: { secret: "do-not-leak" } }, - api: { tls: { cert_path: "another-private-value" } }, + 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" } } }, }), diff --git a/packages/process-compose/src/RestartDecision.ts b/packages/process-compose/src/RestartDecision.ts index 702e00712c..dc1d2fc5ae 100644 --- a/packages/process-compose/src/RestartDecision.ts +++ b/packages/process-compose/src/RestartDecision.ts @@ -13,6 +13,9 @@ export type RestartDecision = } | { readonly _tag: "KeepRunningUnhealthy" }; +export const UNHEALTHY_RESTART_EXHAUSTED_ERROR = + "Health check failed and restart budget was exhausted"; + export function decideRestart(options: { readonly cause: LifecycleCause; readonly policy: RestartPolicy; From 94698466f7b1f38c6f37ff6758437e8c98a229f1 Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 20:53:31 +0200 Subject: [PATCH 23/26] fix(cli): classify generated config defaults --- .../next/config/local-stack-config-parity.ts | 49 +++++++++++-------- .../local-stack-config-parity.unit.test.ts | 6 ++- 2 files changed, 32 insertions(+), 23 deletions(-) diff --git a/apps/cli/src/next/config/local-stack-config-parity.ts b/apps/cli/src/next/config/local-stack-config-parity.ts index e60eabd463..f0b75be733 100644 --- a/apps/cli/src/next/config/local-stack-config-parity.ts +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -143,6 +143,13 @@ const commandOnlyDatabaseField: LocalStackConfigParityDecision = { "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 projectMetadataField: LocalStackConfigParityDecision = { _tag: "not-applicable", presence: "raw-document", @@ -237,10 +244,10 @@ const authHooksParity = { } satisfies Record; const authSmsParity = { - enable_signup: unsupportedRuntimeField, - enable_confirmations: unsupportedRuntimeField, - template: unsupportedRuntimeField, - max_frequency: unsupportedRuntimeField, + enable_signup: unsupportedEnabledProviderField, + enable_confirmations: unsupportedEnabledProviderField, + template: unsupportedEnabledProviderField, + max_frequency: unsupportedEnabledProviderField, twilio: { enabled: unsupportedEnabledProviderField, account_sid: unsupportedEnabledProviderField, @@ -437,20 +444,20 @@ const dbSettingsParity = { const localStackConfigParity = { project_id: projectMetadataField, analytics: { - enabled: unsupportedRuntimeField, - port: unsupportedRuntimeField, - backend: unsupportedRuntimeField, + enabled: unsupportedNonDefaultRuntimeField, + port: unsupportedNonDefaultRuntimeField, + backend: unsupportedNonDefaultRuntimeField, vector_port: unsupportedOptionalRuntimeField, gcp_project_id: unsupportedOptionalRuntimeField, gcp_project_number: unsupportedOptionalRuntimeField, gcp_jwt_path: unsupportedOptionalRuntimeField, } satisfies Record, api: { - enabled: unsupportedRuntimeField, - port: unsupportedRuntimeField, - schemas: unsupportedRuntimeField, - extra_search_path: unsupportedRuntimeField, - max_rows: unsupportedRuntimeField, + enabled: unsupportedNonDefaultRuntimeField, + port: unsupportedNonDefaultRuntimeField, + schemas: unsupportedNonDefaultRuntimeField, + extra_search_path: unsupportedNonDefaultRuntimeField, + max_rows: unsupportedNonDefaultRuntimeField, auto_expose_new_tables: mappedAutoExposeNewTables, tls: { enabled: unsupportedEnabledProviderField, @@ -505,21 +512,21 @@ const localStackConfigParity = { }, }, local_smtp: { - enabled: unsupportedRuntimeField, - port: unsupportedRuntimeField, + enabled: unsupportedNonDefaultRuntimeField, + port: unsupportedNonDefaultRuntimeField, smtp_port: unsupportedOptionalRuntimeField, pop3_port: unsupportedOptionalRuntimeField, admin_email: unsupportedOptionalRuntimeField, sender_name: unsupportedOptionalRuntimeField, } satisfies Record, realtime: { - enabled: unsupportedRuntimeField, - ip_version: unsupportedRuntimeField, - max_header_length: unsupportedRuntimeField, + enabled: unsupportedNonDefaultRuntimeField, + ip_version: unsupportedNonDefaultRuntimeField, + max_header_length: unsupportedNonDefaultRuntimeField, } satisfies Record, storage: { - enabled: unsupportedRuntimeField, - file_size_limit: unsupportedRuntimeField, + enabled: unsupportedNonDefaultRuntimeField, + file_size_limit: unsupportedNonDefaultRuntimeField, image_transformation: { enabled: unsupportedRuntimeField, } satisfies Record, Node>, @@ -554,8 +561,8 @@ const localStackConfigParity = { } satisfies Record, vector: { enabled: unsupportedEnabledProviderField, - max_buckets: unsupportedEnabledProviderField, - max_indexes: unsupportedEnabledProviderField, + max_buckets: hostedConfigurationField, + max_indexes: hostedConfigurationField, buckets: { "*": { decision: unsupportedEnabledProviderField, 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 index dbafe815c1..13f0f7919c 100644 --- 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 @@ -18,8 +18,8 @@ describe("localStackConfigParity", () => { ), ).toEqual({ mapped: 11, - "not-applicable": 10, - "unsupported-blocking": 346, + "not-applicable": 12, + "unsupported-blocking": 344, "unsupported-warning": 6, }); }); @@ -104,6 +104,8 @@ describe("localStackConfigParity", () => { "experimental.pgdelta.format_options", "project_id", "remotes", + "storage.vector.max_buckets", + "storage.vector.max_indexes", ]); }); }); From bf2289a5281f17fc0b859f6a79ce1bcd54a444db Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 21:15:31 +0200 Subject: [PATCH 24/26] test(cli): ignore generated stack defaults --- apps/cli/src/next/config/local-stack-config-parity.ts | 10 +++++----- .../next/config/local-stack-config-parity.unit.test.ts | 5 +++++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/next/config/local-stack-config-parity.ts b/apps/cli/src/next/config/local-stack-config-parity.ts index f0b75be733..686a084e45 100644 --- a/apps/cli/src/next/config/local-stack-config-parity.ts +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -283,7 +283,7 @@ const authParity = { enabled: unsupportedRuntimeField, site_url: unsupportedRuntimeField, additional_redirect_urls: unsupportedRuntimeField, - jwt_expiry: unsupportedRuntimeField, + jwt_expiry: unsupportedNonDefaultRuntimeField, jwt_issuer: unsupportedOptionalRuntimeField, signing_keys_path: unsupportedOptionalRuntimeField, enable_refresh_token_rotation: unsupportedRuntimeField, @@ -468,7 +468,7 @@ const localStackConfigParity = { } satisfies Record, auth: authParity, db: { - port: unsupportedRuntimeField, + port: unsupportedNonDefaultRuntimeField, shadow_port: commandOnlyDatabaseField, health_timeout: unsupportedRuntimeField, major_version: unsupportedRuntimeField, @@ -542,7 +542,7 @@ const localStackConfigParity = { }, }, s3_protocol: { - enabled: unsupportedRuntimeField, + enabled: unsupportedNonDefaultRuntimeField, } satisfies Record, analytics: { enabled: unsupportedEnabledProviderField, @@ -575,8 +575,8 @@ const localStackConfigParity = { } satisfies Record, } satisfies Record, studio: { - enabled: unsupportedRuntimeField, - port: unsupportedRuntimeField, + enabled: unsupportedNonDefaultRuntimeField, + port: unsupportedNonDefaultRuntimeField, api_url: unsupportedRuntimeField, openai_api_key: unsupportedSecretRuntimeField, } satisfies Record, 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 index 13f0f7919c..0b6daeac6a 100644 --- 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 @@ -76,6 +76,11 @@ describe("localStackConfigParity", () => { 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_expiry")?.presence).toBe("non-default-value"); + expect(byPath.get("db.port")?.presence).toBe("non-default-value"); + expect(byPath.get("storage.s3_protocol.enabled")?.presence).toBe("non-default-value"); + expect(byPath.get("studio.enabled")?.presence).toBe("non-default-value"); + expect(byPath.get("studio.port")?.presence).toBe("non-default-value"); expect(byPath.get("auth.jwt_secret")?.presence).toBe("effective-global-secret"); expect(byPath.get("auth.external.*")?.presence).toBe("enabled-subtree"); expect(byPath.get("storage.image_transformation.enabled")?.presence).toBe("raw-document"); From 70d97dadd6642475d15a190825a09817f50dd39c Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 21:35:08 +0200 Subject: [PATCH 25/26] fix(cli): complete local config parity inventory --- .../next/config/local-stack-config-parity.ts | 44 +++++++++++++------ .../local-stack-config-parity.unit.test.ts | 16 +++++-- 2 files changed, 43 insertions(+), 17 deletions(-) diff --git a/apps/cli/src/next/config/local-stack-config-parity.ts b/apps/cli/src/next/config/local-stack-config-parity.ts index 686a084e45..58cf0e5713 100644 --- a/apps/cli/src/next/config/local-stack-config-parity.ts +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -150,6 +150,13 @@ const hostedConfigurationField: LocalStackConfigParityDecision = { "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 projectMetadataField: LocalStackConfigParityDecision = { _tag: "not-applicable", presence: "raw-document", @@ -195,7 +202,7 @@ const authHookParity = { } satisfies Record; const authRateLimitParity = { - email_sent: unsupportedRuntimeField, + email_sent: legacyIgnoredLocalRuntimeField, sms_sent: unsupportedRuntimeField, anonymous_users: unsupportedRuntimeField, token_refresh: unsupportedRuntimeField, @@ -280,7 +287,16 @@ const authSmsParity = { } satisfies Record; const authParity = { - enabled: unsupportedRuntimeField, + enabled: unsupportedNonDefaultRuntimeField, + external_url: unsupportedRuntimeField, + passkey: { + enabled: unsupportedEnabledProviderField, + }, + webauthn: { + rp_display_name: unsupportedRuntimeField, + rp_id: unsupportedRuntimeField, + rp_origins: unsupportedRuntimeField, + }, site_url: unsupportedRuntimeField, additional_redirect_urls: unsupportedRuntimeField, jwt_expiry: unsupportedNonDefaultRuntimeField, @@ -289,7 +305,7 @@ const authParity = { enable_refresh_token_rotation: unsupportedRuntimeField, refresh_token_reuse_interval: unsupportedRuntimeField, enable_manual_linking: unsupportedRuntimeField, - enable_signup: unsupportedRuntimeField, + enable_signup: unsupportedNonDefaultRuntimeField, enable_anonymous_sign_ins: unsupportedRuntimeField, minimum_password_length: unsupportedRuntimeField, password_requirements: unsupportedRuntimeField, @@ -328,13 +344,13 @@ const authParity = { inactivity_timeout: unsupportedOptionalRuntimeField, } satisfies Record, Node>, email: { - enable_signup: unsupportedRuntimeField, - double_confirm_changes: unsupportedRuntimeField, - enable_confirmations: unsupportedRuntimeField, - secure_password_change: unsupportedRuntimeField, - max_frequency: unsupportedRuntimeField, - otp_length: unsupportedRuntimeField, - otp_expiry: unsupportedRuntimeField, + enable_signup: unsupportedNonDefaultRuntimeField, + double_confirm_changes: unsupportedNonDefaultRuntimeField, + enable_confirmations: unsupportedNonDefaultRuntimeField, + secure_password_change: unsupportedNonDefaultRuntimeField, + max_frequency: unsupportedNonDefaultRuntimeField, + otp_length: unsupportedNonDefaultRuntimeField, + otp_expiry: unsupportedNonDefaultRuntimeField, smtp: { enabled: unsupportedRuntimeField, host: unsupportedOptionalRuntimeField, @@ -368,10 +384,10 @@ const authParity = { external: authExternalWithCustomParity, web3: { solana: { - enabled: unsupportedRuntimeField, + enabled: unsupportedNonDefaultRuntimeField, } satisfies Record, ethereum: { - enabled: unsupportedRuntimeField, + enabled: unsupportedNonDefaultRuntimeField, } satisfies Record, } satisfies Record, oauth_server: { @@ -403,7 +419,7 @@ const authParity = { issuer_url: unsupportedEnabledProviderField, } satisfies Record, } satisfies Record, -} satisfies Record; +} satisfies Record & LocalStackConfigParitySection; const dbSettingsParity = { effective_cache_size: unsupportedOptionalRuntimeField, @@ -471,7 +487,7 @@ const localStackConfigParity = { port: unsupportedNonDefaultRuntimeField, shadow_port: commandOnlyDatabaseField, health_timeout: unsupportedRuntimeField, - major_version: unsupportedRuntimeField, + major_version: unsupportedNonDefaultRuntimeField, pooler: { enabled: unsupportedRuntimeField, port: unsupportedRuntimeField, 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 index 0b6daeac6a..c4a3a3effe 100644 --- 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 @@ -7,7 +7,7 @@ describe("localStackConfigParity", () => { it("classifies every fixed project-config leaf exactly once", () => { const paths = entries.map(({ path }) => path); - expect(paths).toHaveLength(373); + expect(paths).toHaveLength(378); expect(new Set(paths).size).toBe(paths.length); expect( Object.fromEntries( @@ -18,8 +18,8 @@ describe("localStackConfigParity", () => { ), ).toEqual({ mapped: 11, - "not-applicable": 12, - "unsupported-blocking": 344, + "not-applicable": 13, + "unsupported-blocking": 348, "unsupported-warning": 6, }); }); @@ -77,6 +77,15 @@ describe("localStackConfigParity", () => { 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_expiry")?.presence).toBe("non-default-value"); + expect(byPath.get("auth.enabled")?.presence).toBe("non-default-value"); + expect(byPath.get("auth.enable_signup")?.presence).toBe("non-default-value"); + expect(byPath.get("auth.email.enable_signup")?.presence).toBe("non-default-value"); + expect(byPath.get("auth.email.enable_confirmations")?.presence).toBe("non-default-value"); + expect(byPath.get("auth.web3.solana.enabled")?.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("db.port")?.presence).toBe("non-default-value"); expect(byPath.get("storage.s3_protocol.enabled")?.presence).toBe("non-default-value"); expect(byPath.get("studio.enabled")?.presence).toBe("non-default-value"); @@ -99,6 +108,7 @@ describe("localStackConfigParity", () => { .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", From de9db547680f15c187ba58f1600ea9e4b8dcf45a Mon Sep 17 00:00:00 2001 From: Julien Goux Date: Wed, 5 Aug 2026 21:52:53 +0200 Subject: [PATCH 26/26] test(cli): refine parity presence semantics --- .../next/config/local-stack-config-parity.ts | 142 +++++++++--------- .../local-stack-config-parity.unit.test.ts | 10 +- 2 files changed, 80 insertions(+), 72 deletions(-) diff --git a/apps/cli/src/next/config/local-stack-config-parity.ts b/apps/cli/src/next/config/local-stack-config-parity.ts index 58cf0e5713..87c9333cb9 100644 --- a/apps/cli/src/next/config/local-stack-config-parity.ts +++ b/apps/cli/src/next/config/local-stack-config-parity.ts @@ -89,11 +89,11 @@ const unsupportedNonDefaultRuntimeField: LocalStackConfigParityDecision = { "Only a value that differs from the generated project-config default changes local runtime behavior.", }; -const unsupportedEnabledProviderField: LocalStackConfigParityDecision = { +const unsupportedEnabledSubtreeField: LocalStackConfigParityDecision = { _tag: "unsupported-blocking", presence: "enabled-subtree", rationale: - "This setting changes local authentication behavior only when its provider is effectively enabled; generated disabled provider stubs do not count.", + "This setting changes local runtime behavior only when its enclosing feature is effectively enabled; generated disabled stubs do not count.", }; const mappedAutoExposeNewTables: LocalStackConfigParityDecision = { @@ -179,13 +179,13 @@ const unsupportedFutureRuntimeField: LocalStackConfigParityDecision = { }; const authExternalProviderParity = { - enabled: unsupportedEnabledProviderField, - client_id: unsupportedEnabledProviderField, + enabled: unsupportedEnabledSubtreeField, + client_id: unsupportedEnabledSubtreeField, secret: unsupportedSecretRuntimeField, - url: unsupportedEnabledProviderField, - redirect_uri: unsupportedEnabledProviderField, - skip_nonce_check: unsupportedEnabledProviderField, - email_optional: unsupportedEnabledProviderField, + url: unsupportedEnabledSubtreeField, + redirect_uri: unsupportedEnabledSubtreeField, + skip_nonce_check: unsupportedEnabledSubtreeField, + email_optional: unsupportedEnabledSubtreeField, } satisfies Record; type AuthExternalParity = { @@ -196,8 +196,8 @@ type AuthExternalParity = { }; const authHookParity = { - enabled: unsupportedRuntimeField, - uri: unsupportedOptionalRuntimeField, + enabled: unsupportedEnabledSubtreeField, + uri: unsupportedEnabledSubtreeField, secrets: unsupportedSecretRuntimeField, } satisfies Record; @@ -236,7 +236,7 @@ const authExternalParity = { const authExternalWithCustomParity = { ...authExternalParity, "*": { - decision: unsupportedEnabledProviderField, + decision: unsupportedEnabledSubtreeField, children: authExternalProviderParity, }, } satisfies LocalStackConfigParitySection; @@ -251,36 +251,36 @@ const authHooksParity = { } satisfies Record; const authSmsParity = { - enable_signup: unsupportedEnabledProviderField, - enable_confirmations: unsupportedEnabledProviderField, - template: unsupportedEnabledProviderField, - max_frequency: unsupportedEnabledProviderField, + enable_signup: unsupportedEnabledSubtreeField, + enable_confirmations: unsupportedEnabledSubtreeField, + template: unsupportedEnabledSubtreeField, + max_frequency: unsupportedEnabledSubtreeField, twilio: { - enabled: unsupportedEnabledProviderField, - account_sid: unsupportedEnabledProviderField, - message_service_sid: unsupportedEnabledProviderField, + enabled: unsupportedEnabledSubtreeField, + account_sid: unsupportedEnabledSubtreeField, + message_service_sid: unsupportedEnabledSubtreeField, auth_token: unsupportedSecretRuntimeField, } satisfies Record, twilio_verify: { - enabled: unsupportedEnabledProviderField, - account_sid: unsupportedEnabledProviderField, - message_service_sid: unsupportedEnabledProviderField, + enabled: unsupportedEnabledSubtreeField, + account_sid: unsupportedEnabledSubtreeField, + message_service_sid: unsupportedEnabledSubtreeField, auth_token: unsupportedSecretRuntimeField, } satisfies Record, messagebird: { - enabled: unsupportedEnabledProviderField, - originator: unsupportedEnabledProviderField, + enabled: unsupportedEnabledSubtreeField, + originator: unsupportedEnabledSubtreeField, access_key: unsupportedSecretRuntimeField, } satisfies Record, textlocal: { - enabled: unsupportedEnabledProviderField, - sender: unsupportedEnabledProviderField, + enabled: unsupportedEnabledSubtreeField, + sender: unsupportedEnabledSubtreeField, api_key: unsupportedSecretRuntimeField, } satisfies Record, vonage: { - enabled: unsupportedEnabledProviderField, - from: unsupportedEnabledProviderField, - api_key: unsupportedEnabledProviderField, + enabled: unsupportedEnabledSubtreeField, + from: unsupportedEnabledSubtreeField, + api_key: unsupportedEnabledSubtreeField, api_secret: unsupportedSecretRuntimeField, } satisfies Record, test_otp: unsupportedOptionalRuntimeField, @@ -290,7 +290,7 @@ const authParity = { enabled: unsupportedNonDefaultRuntimeField, external_url: unsupportedRuntimeField, passkey: { - enabled: unsupportedEnabledProviderField, + enabled: unsupportedEnabledSubtreeField, }, webauthn: { rp_display_name: unsupportedRuntimeField, @@ -323,21 +323,21 @@ const authParity = { hook: authHooksParity, mfa: { totp: { - enroll_enabled: unsupportedRuntimeField, - verify_enabled: unsupportedRuntimeField, + enroll_enabled: unsupportedNonDefaultRuntimeField, + verify_enabled: unsupportedNonDefaultRuntimeField, } satisfies Record, phone: { - enroll_enabled: unsupportedRuntimeField, - verify_enabled: unsupportedRuntimeField, - otp_length: unsupportedRuntimeField, - template: unsupportedRuntimeField, - max_frequency: unsupportedRuntimeField, + enroll_enabled: unsupportedNonDefaultRuntimeField, + verify_enabled: unsupportedNonDefaultRuntimeField, + otp_length: unsupportedEnabledSubtreeField, + template: unsupportedEnabledSubtreeField, + max_frequency: unsupportedEnabledSubtreeField, } satisfies Record, web_authn: { - enroll_enabled: unsupportedRuntimeField, - verify_enabled: unsupportedRuntimeField, + enroll_enabled: unsupportedNonDefaultRuntimeField, + verify_enabled: unsupportedNonDefaultRuntimeField, } satisfies Record, - max_enrolled_factors: unsupportedRuntimeField, + max_enrolled_factors: unsupportedNonDefaultRuntimeField, } satisfies Record, sessions: { timebox: unsupportedOptionalRuntimeField, @@ -352,13 +352,13 @@ const authParity = { otp_length: unsupportedNonDefaultRuntimeField, otp_expiry: unsupportedNonDefaultRuntimeField, smtp: { - enabled: unsupportedRuntimeField, - host: unsupportedOptionalRuntimeField, - port: unsupportedOptionalRuntimeField, - user: unsupportedOptionalRuntimeField, + enabled: unsupportedEnabledSubtreeField, + host: unsupportedEnabledSubtreeField, + port: unsupportedEnabledSubtreeField, + user: unsupportedEnabledSubtreeField, pass: unsupportedSecretRuntimeField, - admin_email: unsupportedOptionalRuntimeField, - sender_name: unsupportedOptionalRuntimeField, + admin_email: unsupportedEnabledSubtreeField, + sender_name: unsupportedEnabledSubtreeField, } satisfies Record, Node>, template: { "*": { @@ -391,32 +391,32 @@ const authParity = { } satisfies Record, } satisfies Record, oauth_server: { - enabled: unsupportedEnabledProviderField, - authorization_url_path: unsupportedEnabledProviderField, - allow_dynamic_registration: unsupportedEnabledProviderField, + enabled: unsupportedEnabledSubtreeField, + authorization_url_path: unsupportedEnabledSubtreeField, + allow_dynamic_registration: unsupportedEnabledSubtreeField, } satisfies Record, third_party: { firebase: { - enabled: unsupportedEnabledProviderField, - project_id: unsupportedEnabledProviderField, + enabled: unsupportedEnabledSubtreeField, + project_id: unsupportedEnabledSubtreeField, } satisfies Record, auth0: { - enabled: unsupportedEnabledProviderField, - tenant: unsupportedEnabledProviderField, - tenant_region: unsupportedEnabledProviderField, + enabled: unsupportedEnabledSubtreeField, + tenant: unsupportedEnabledSubtreeField, + tenant_region: unsupportedEnabledSubtreeField, } satisfies Record, aws_cognito: { - enabled: unsupportedEnabledProviderField, - user_pool_id: unsupportedEnabledProviderField, - user_pool_region: unsupportedEnabledProviderField, + enabled: unsupportedEnabledSubtreeField, + user_pool_id: unsupportedEnabledSubtreeField, + user_pool_region: unsupportedEnabledSubtreeField, } satisfies Record, clerk: { - enabled: unsupportedEnabledProviderField, - domain: unsupportedEnabledProviderField, + enabled: unsupportedEnabledSubtreeField, + domain: unsupportedEnabledSubtreeField, } satisfies Record, workos: { - enabled: unsupportedEnabledProviderField, - issuer_url: unsupportedEnabledProviderField, + enabled: unsupportedEnabledSubtreeField, + issuer_url: unsupportedEnabledSubtreeField, } satisfies Record, } satisfies Record, } satisfies Record & LocalStackConfigParitySection; @@ -476,9 +476,9 @@ const localStackConfigParity = { max_rows: unsupportedNonDefaultRuntimeField, auto_expose_new_tables: mappedAutoExposeNewTables, tls: { - enabled: unsupportedEnabledProviderField, - cert_path: unsupportedEnabledProviderField, - key_path: unsupportedEnabledProviderField, + enabled: unsupportedEnabledSubtreeField, + cert_path: unsupportedEnabledSubtreeField, + key_path: unsupportedEnabledSubtreeField, } satisfies Record, external_url: unsupportedOptionalRuntimeField, } satisfies Record, @@ -561,13 +561,13 @@ const localStackConfigParity = { enabled: unsupportedNonDefaultRuntimeField, } satisfies Record, analytics: { - enabled: unsupportedEnabledProviderField, - max_namespaces: unsupportedEnabledProviderField, - max_tables: unsupportedEnabledProviderField, - max_catalogs: unsupportedEnabledProviderField, + enabled: unsupportedEnabledSubtreeField, + max_namespaces: unsupportedEnabledSubtreeField, + max_tables: unsupportedEnabledSubtreeField, + max_catalogs: unsupportedEnabledSubtreeField, buckets: { "*": { - decision: unsupportedEnabledProviderField, + decision: unsupportedEnabledSubtreeField, children: {} satisfies Record< keyof ProjectConfig["storage"]["analytics"]["buckets"][string], Node @@ -576,12 +576,12 @@ const localStackConfigParity = { }, } satisfies Record, vector: { - enabled: unsupportedEnabledProviderField, + enabled: unsupportedEnabledSubtreeField, max_buckets: hostedConfigurationField, max_indexes: hostedConfigurationField, buckets: { "*": { - decision: unsupportedEnabledProviderField, + decision: unsupportedEnabledSubtreeField, children: {} satisfies Record< keyof ProjectConfig["storage"]["vector"]["buckets"][string], Node @@ -593,7 +593,7 @@ const localStackConfigParity = { studio: { enabled: unsupportedNonDefaultRuntimeField, port: unsupportedNonDefaultRuntimeField, - api_url: unsupportedRuntimeField, + api_url: unsupportedNonDefaultRuntimeField, openai_api_key: unsupportedSecretRuntimeField, } satisfies Record, experimental: { 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 index c4a3a3effe..e3ba1a31dc 100644 --- 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 @@ -69,7 +69,8 @@ describe("localStackConfigParity", () => { 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("raw-document"); + 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"); @@ -81,7 +82,13 @@ describe("localStackConfigParity", () => { expect(byPath.get("auth.enable_signup")?.presence).toBe("non-default-value"); expect(byPath.get("auth.email.enable_signup")?.presence).toBe("non-default-value"); expect(byPath.get("auth.email.enable_confirmations")?.presence).toBe("non-default-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"); @@ -90,6 +97,7 @@ describe("localStackConfigParity", () => { expect(byPath.get("storage.s3_protocol.enabled")?.presence).toBe("non-default-value"); expect(byPath.get("studio.enabled")?.presence).toBe("non-default-value"); expect(byPath.get("studio.port")?.presence).toBe("non-default-value"); + expect(byPath.get("studio.api_url")?.presence).toBe("non-default-value"); expect(byPath.get("auth.jwt_secret")?.presence).toBe("effective-global-secret"); expect(byPath.get("auth.external.*")?.presence).toBe("enabled-subtree"); expect(byPath.get("storage.image_transformation.enabled")?.presence).toBe("raw-document");