diff --git a/api/overlay.yaml b/api/overlay.yaml index 7843f3d86b..b59831268b 100644 --- a/api/overlay.yaml +++ b/api/overlay.yaml @@ -41,6 +41,9 @@ actions: - target: $.components.schemas.DiskRequestBody.properties.attributes.discriminator description: Replaces discriminated union with concrete type remove: true +- target: $.components.schemas.JitStateResponse.discriminator + description: Replaces discriminated union with concrete type + remove: true - target: $.paths.*.*.parameters[?(@.name=='branch_id_or_ref')] update: schema: diff --git a/cmd/db_schema_declarative.go b/cmd/db_schema_declarative.go index 8cb98b9912..896b7bd582 100644 --- a/cmd/db_schema_declarative.go +++ b/cmd/db_schema_declarative.go @@ -52,7 +52,12 @@ var ( // If the user has passed the --experimental flag and pg-delta is not enabled, enable it // so in the rest of the code we can know that we're running pg-delta logic. if viper.GetBool("EXPERIMENTAL") && !utils.IsPgDeltaEnabled() { - utils.Config.Experimental.PgDelta = &config.PgDeltaConfig{Enabled: true} + if utils.Config.Experimental.PgDelta == nil { + utils.Config.Experimental.PgDelta = &config.PgDeltaConfig{Enabled: true} + } else { + // We preserve the version set into `.temp/pgdelta-version` by just enabling pg-delta. + utils.Config.Experimental.PgDelta.Enabled = true + } } if !utils.IsPgDeltaEnabled() { utils.CmdSuggestion = fmt.Sprintf("Either pass %s or add %s with %s to %s", diff --git a/cmd/root.go b/cmd/root.go index f8468a3e58..3fc341b370 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -193,6 +193,9 @@ func Execute() { if err != nil { fmt.Fprintln(utils.GetDebugLogger(), err) } + if hint := utils.SuggestClaudePlugin(); hint != "" { + fmt.Fprintln(os.Stderr, hint) + } if semver.Compare(version, "v"+utils.Version) > 0 { fmt.Fprintln(os.Stderr, suggestUpgrade(version)) } diff --git a/docs/supabase/start.md b/docs/supabase/start.md index a590610f11..7db99b8c49 100644 --- a/docs/supabase/start.md +++ b/docs/supabase/start.md @@ -9,3 +9,5 @@ All service containers are started by default. You can exclude those not needed > It is recommended to have at least 7GB of RAM to start all services. Health checks are automatically added to verify the started containers. Use `--ignore-health-check` flag to ignore these errors. + +> If the CLI is running inside a dev container with the Docker socket bind-mounted, set the `SUPABASE_SERVICES_HOSTNAME` environment variable to the hostname reachable from inside that container, such as `host.docker.internal`. diff --git a/internal/db/diff/pgdelta.go b/internal/db/diff/pgdelta.go index 70b30a95a1..dd6af84924 100644 --- a/internal/db/diff/pgdelta.go +++ b/internal/db/diff/pgdelta.go @@ -6,6 +6,7 @@ import ( _ "embed" "encoding/json" "os" + "path/filepath" "strings" "github.com/go-errors/errors" @@ -13,6 +14,7 @@ import ( "github.com/jackc/pgx/v4" "github.com/supabase/cli/internal/gen/types" "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/pkg/config" ) //go:embed templates/pgdelta.ts @@ -47,12 +49,14 @@ func isPostgresURL(ref string) bool { // containerRef translates a host-relative catalog file path into the absolute // path where it appears inside the edge runtime container (CWD mounted at -// /workspace). Postgres URLs and empty strings pass through unchanged. +// /workspace). Postgres URLs and empty strings pass through unchanged. Path +// separators are normalised to forward slashes so Windows paths (with `\`) +// resolve correctly inside the Linux container. func containerRef(ref string) string { if ref == "" || isPostgresURL(ref) { return ref } - return "/workspace/" + ref + return "/workspace/" + filepath.ToSlash(ref) } // pgDeltaFormatOptions returns the experimental.pgdelta.format_options config for @@ -101,7 +105,8 @@ func DiffPgDeltaRef(ctx context.Context, sourceRef, targetRef string, schema []s binds = append(binds, cwd+":/workspace") } var stdout, stderr bytes.Buffer - if err := utils.RunEdgeRuntimeScript(ctx, env, pgDeltaScript, binds, "error diffing schema", &stdout, &stderr); err != nil { + script := config.InterpolatePgDeltaScript(config.Config(&utils.Config), pgDeltaScript) + if err := utils.RunEdgeRuntimeScript(ctx, env, script, binds, "error diffing schema", &stdout, &stderr); err != nil { return "", err } return stdout.String(), nil @@ -140,9 +145,13 @@ func DeclarativeExportPgDeltaRef(ctx context.Context, sourceRef, targetRef strin binds = append(binds, cwd+":/workspace") } var stdout, stderr bytes.Buffer - if err := utils.RunEdgeRuntimeScript(ctx, env, pgDeltaDeclarativeExportScript, binds, "error exporting declarative schema", &stdout, &stderr); err != nil { + script := config.InterpolatePgDeltaScript(config.Config(&utils.Config), pgDeltaDeclarativeExportScript) + if err := utils.RunEdgeRuntimeScript(ctx, env, script, binds, "error exporting declarative schema", &stdout, &stderr); err != nil { return DeclarativeOutput{}, err } + if stdout.Len() == 0 { + return DeclarativeOutput{}, errors.Errorf("error exporting declarative schema: edge-runtime script produced no output:\n%s", stderr.String()) + } var result DeclarativeOutput if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { return DeclarativeOutput{}, errors.Errorf("failed to parse declarative export output: %w", err) @@ -173,8 +182,13 @@ func ExportCatalogPgDelta(ctx context.Context, targetRef, role string, options . binds = append(binds, cwd+":/workspace") } var stdout, stderr bytes.Buffer - if err := utils.RunEdgeRuntimeScript(ctx, env, pgDeltaCatalogExportScript, binds, "error exporting pg-delta catalog", &stdout, &stderr); err != nil { + script := config.InterpolatePgDeltaScript(config.Config(&utils.Config), pgDeltaCatalogExportScript) + if err := utils.RunEdgeRuntimeScript(ctx, env, script, binds, "error exporting pg-delta catalog", &stdout, &stderr); err != nil { return "", err } - return strings.TrimSpace(stdout.String()), nil + snapshot := strings.TrimSpace(stdout.String()) + if len(snapshot) == 0 { + return "", errors.Errorf("error exporting pg-delta catalog: edge-runtime script produced no output:\n%s", stderr.String()) + } + return snapshot, nil } diff --git a/internal/db/diff/pgdelta_test.go b/internal/db/diff/pgdelta_test.go new file mode 100644 index 0000000000..671414a069 --- /dev/null +++ b/internal/db/diff/pgdelta_test.go @@ -0,0 +1,34 @@ +package diff + +import ( + "runtime" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestContainerRef(t *testing.T) { + t.Run("passes empty string through", func(t *testing.T) { + assert.Equal(t, "", containerRef("")) + }) + + t.Run("passes postgres URLs through", func(t *testing.T) { + assert.Equal(t, "postgresql://user@host:5432/db", containerRef("postgresql://user@host:5432/db")) + assert.Equal(t, "postgres://user@host:5432/db", containerRef("postgres://user@host:5432/db")) + }) + + t.Run("normalises Windows path separators", func(t *testing.T) { + if runtime.GOOS != "windows" { + t.Skip("path separator behaviour is Windows-only") + } + // On Windows, filepath.Join produces backslashes which the Linux + // container cannot read; containerRef must convert them. + ref := `supabase\.temp\pgdelta\catalog-baseline-17.6.1.106.json` + assert.Equal(t, "/workspace/supabase/.temp/pgdelta/catalog-baseline-17.6.1.106.json", containerRef(ref)) + }) + + t.Run("leaves unix paths untouched", func(t *testing.T) { + ref := "supabase/.temp/pgdelta/catalog-baseline-17.6.1.106.json" + assert.Equal(t, "/workspace/supabase/.temp/pgdelta/catalog-baseline-17.6.1.106.json", containerRef(ref)) + }) +} diff --git a/internal/db/pgcache/cache.go b/internal/db/pgcache/cache.go index db12199475..aeb1ebfbce 100644 --- a/internal/db/pgcache/cache.go +++ b/internal/db/pgcache/cache.go @@ -13,12 +13,14 @@ import ( "strings" "time" + "github.com/go-errors/errors" "github.com/jackc/pgconn" "github.com/jackc/pgx/v4" "github.com/spf13/afero" "github.com/spf13/viper" "github.com/supabase/cli/internal/gen/types" "github.com/supabase/cli/internal/utils" + "github.com/supabase/cli/pkg/config" "github.com/supabase/cli/pkg/migration" ) @@ -252,8 +254,13 @@ func exportCatalog(ctx context.Context, targetRef string, options ...func(*pgx.C } binds := []string{utils.EdgeRuntimeId + ":/root/.cache/deno:rw"} var stdout, stderr bytes.Buffer - if err := utils.RunEdgeRuntimeScript(ctx, env, pgDeltaCatalogExportTS, binds, "error exporting pg-delta catalog", &stdout, &stderr); err != nil { + script := config.InterpolatePgDeltaScript(config.Config(&utils.Config), pgDeltaCatalogExportTS) + if err := utils.RunEdgeRuntimeScript(ctx, env, script, binds, "error exporting pg-delta catalog", &stdout, &stderr); err != nil { return "", err } - return strings.TrimSpace(stdout.String()), nil + snapshot := strings.TrimSpace(stdout.String()) + if len(snapshot) == 0 { + return "", errors.Errorf("error exporting pg-delta catalog: edge-runtime script produced no output:\n%s", stderr.String()) + } + return snapshot, nil } diff --git a/internal/db/start/start.go b/internal/db/start/start.go index a30619398f..cf9cdc8dfd 100644 --- a/internal/db/start/start.go +++ b/internal/db/start/start.go @@ -320,7 +320,7 @@ func initAuthJob(host string) utils.DockerJob { return utils.DockerJob{ Image: utils.Config.Auth.Image, Env: []string{ - "API_EXTERNAL_URL=" + utils.Config.Api.ExternalUrl, + "API_EXTERNAL_URL=" + utils.Config.AuthExternalURL(), "GOTRUE_LOG_LEVEL=error", "GOTRUE_DB_DRIVER=postgres", fmt.Sprintf("GOTRUE_DB_DATABASE_URL=postgresql://supabase_auth_admin:%s@%s:5432/postgres", utils.Config.Db.Password, host), diff --git a/internal/functions/serve/serve.go b/internal/functions/serve/serve.go index dc82f532e4..38ca9f7e2e 100644 --- a/internal/functions/serve/serve.go +++ b/internal/functions/serve/serve.go @@ -223,6 +223,16 @@ EOF container.HostConfig{ Binds: binds, PortBindings: portBindings, + Resources: container.Resources{ + // Raise nofile to accommodate FD usage from many concurrent Deno isolates (see #5151). + Ulimits: []*container.Ulimit{ + { + Name: "nofile", + Soft: 65536, + Hard: 65536, + }, + }, + }, }, network.NetworkingConfig{ EndpointsConfig: map[string]*network.EndpointSettings{ diff --git a/internal/login/login.go b/internal/login/login.go index 18ba9ee640..8394db2c6d 100644 --- a/internal/login/login.go +++ b/internal/login/login.go @@ -172,6 +172,9 @@ func Run(ctx context.Context, stdout io.Writer, params RunParams) error { } handleTelemetryAfterLogin(ctx, params) fmt.Println(loggedInMsg) + if hint := utils.SuggestClaudePlugin(); hint != "" { + fmt.Fprintln(os.Stderr, hint) + } return nil } @@ -223,6 +226,9 @@ func Run(ctx context.Context, stdout io.Writer, params RunParams) error { fmt.Fprintf(stdout, "Token %s created successfully.\n\n", utils.Bold(params.TokenName)) fmt.Fprintln(stdout, loggedInMsg) + if hint := utils.SuggestClaudePlugin(); hint != "" { + fmt.Fprintln(os.Stderr, hint) + } return nil } diff --git a/internal/pgdelta/apply.go b/internal/pgdelta/apply.go index 2c57eb506b..f9009a3202 100644 --- a/internal/pgdelta/apply.go +++ b/internal/pgdelta/apply.go @@ -15,6 +15,7 @@ import ( "github.com/spf13/afero" "github.com/spf13/viper" "github.com/supabase/cli/internal/utils" + pkgconfig "github.com/supabase/cli/pkg/config" ) //go:embed templates/pgdelta_declarative_apply.ts @@ -321,7 +322,8 @@ func ApplyDeclarative(ctx context.Context, config pgconn.Config, fsys afero.Fs) fmt.Fprintln(os.Stderr, "Applying declarative schemas via pg-delta...") var stdout, stderr bytes.Buffer - if err := utils.RunEdgeRuntimeScript(ctx, env, pgDeltaDeclarativeApplyScript, binds, "error running pg-delta script", &stdout, &stderr); err != nil { + script := pkgconfig.InterpolatePgDeltaScript(pkgconfig.Config(&utils.Config), pgDeltaDeclarativeApplyScript) + if err := utils.RunEdgeRuntimeScript(ctx, env, script, binds, "error running pg-delta script", &stdout, &stderr); err != nil { return err } diff --git a/internal/start/start.go b/internal/start/start.go index 846e87e4b7..dd195cfb66 100644 --- a/internal/start/start.go +++ b/internal/start/start.go @@ -573,78 +573,7 @@ EOF // Start GoTrue. if utils.Config.Auth.Enabled && !isContainerExcluded(utils.Config.Auth.Image, excluded) { - var testOTP bytes.Buffer - if len(utils.Config.Auth.Sms.TestOTP) > 0 { - formatMapForEnvConfig(utils.Config.Auth.Sms.TestOTP, &testOTP) - } - - env := []string{ - "API_EXTERNAL_URL=" + utils.Config.Api.ExternalUrl, - - "GOTRUE_API_HOST=0.0.0.0", - "GOTRUE_API_PORT=9999", - - "GOTRUE_DB_DRIVER=postgres", - fmt.Sprintf("GOTRUE_DB_DATABASE_URL=postgresql://supabase_auth_admin:%s@%s:%d/%s", dbConfig.Password, dbConfig.Host, dbConfig.Port, dbConfig.Database), - - "GOTRUE_SITE_URL=" + utils.Config.Auth.SiteUrl, - "GOTRUE_URI_ALLOW_LIST=" + strings.Join(utils.Config.Auth.AdditionalRedirectUrls, ","), - fmt.Sprintf("GOTRUE_DISABLE_SIGNUP=%v", !utils.Config.Auth.EnableSignup), - - "GOTRUE_JWT_ADMIN_ROLES=service_role", - "GOTRUE_JWT_AUD=authenticated", - "GOTRUE_JWT_DEFAULT_GROUP_NAME=authenticated", - fmt.Sprintf("GOTRUE_JWT_EXP=%v", utils.Config.Auth.JwtExpiry), - "GOTRUE_JWT_SECRET=" + utils.Config.Auth.JwtSecret.Value, - "GOTRUE_JWT_ISSUER=" + utils.Config.Auth.JwtIssuer, - - fmt.Sprintf("GOTRUE_EXTERNAL_EMAIL_ENABLED=%v", utils.Config.Auth.Email.EnableSignup), - fmt.Sprintf("GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED=%v", utils.Config.Auth.Email.DoubleConfirmChanges), - fmt.Sprintf("GOTRUE_MAILER_AUTOCONFIRM=%v", !utils.Config.Auth.Email.EnableConfirmations), - fmt.Sprintf("GOTRUE_MAILER_OTP_LENGTH=%v", utils.Config.Auth.Email.OtpLength), - fmt.Sprintf("GOTRUE_MAILER_OTP_EXP=%v", utils.Config.Auth.Email.OtpExpiry), - "GOTRUE_MAILER_TEMPLATE_RELOADING_ENABLED=true", - - fmt.Sprintf("GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=%v", utils.Config.Auth.EnableAnonymousSignIns), - - fmt.Sprintf("GOTRUE_SMTP_MAX_FREQUENCY=%v", utils.Config.Auth.Email.MaxFrequency), - - fmt.Sprintf("GOTRUE_MAILER_URLPATHS_INVITE=%s/verify", utils.Config.Auth.JwtIssuer), - fmt.Sprintf("GOTRUE_MAILER_URLPATHS_CONFIRMATION=%s/verify", utils.Config.Auth.JwtIssuer), - fmt.Sprintf("GOTRUE_MAILER_URLPATHS_RECOVERY=%s/verify", utils.Config.Auth.JwtIssuer), - fmt.Sprintf("GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE=%s/verify", utils.Config.Auth.JwtIssuer), - "GOTRUE_RATE_LIMIT_EMAIL_SENT=360000", - - fmt.Sprintf("GOTRUE_EXTERNAL_PHONE_ENABLED=%v", utils.Config.Auth.Sms.EnableSignup), - fmt.Sprintf("GOTRUE_SMS_AUTOCONFIRM=%v", !utils.Config.Auth.Sms.EnableConfirmations), - fmt.Sprintf("GOTRUE_SMS_MAX_FREQUENCY=%v", utils.Config.Auth.Sms.MaxFrequency), - "GOTRUE_SMS_OTP_EXP=6000", - "GOTRUE_SMS_OTP_LENGTH=6", - fmt.Sprintf("GOTRUE_SMS_TEMPLATE=%v", utils.Config.Auth.Sms.Template), - "GOTRUE_SMS_TEST_OTP=" + testOTP.String(), - - fmt.Sprintf("GOTRUE_PASSWORD_MIN_LENGTH=%v", utils.Config.Auth.MinimumPasswordLength), - fmt.Sprintf("GOTRUE_PASSWORD_REQUIRED_CHARACTERS=%v", utils.Config.Auth.PasswordRequirements.ToChar()), - fmt.Sprintf("GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED=%v", utils.Config.Auth.EnableRefreshTokenRotation), - fmt.Sprintf("GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL=%v", utils.Config.Auth.RefreshTokenReuseInterval), - fmt.Sprintf("GOTRUE_SECURITY_MANUAL_LINKING_ENABLED=%v", utils.Config.Auth.EnableManualLinking), - fmt.Sprintf("GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION=%v", utils.Config.Auth.Email.SecurePasswordChange), - fmt.Sprintf("GOTRUE_MFA_PHONE_ENROLL_ENABLED=%v", utils.Config.Auth.MFA.Phone.EnrollEnabled), - fmt.Sprintf("GOTRUE_MFA_PHONE_VERIFY_ENABLED=%v", utils.Config.Auth.MFA.Phone.VerifyEnabled), - fmt.Sprintf("GOTRUE_MFA_TOTP_ENROLL_ENABLED=%v", utils.Config.Auth.MFA.TOTP.EnrollEnabled), - fmt.Sprintf("GOTRUE_MFA_TOTP_VERIFY_ENABLED=%v", utils.Config.Auth.MFA.TOTP.VerifyEnabled), - fmt.Sprintf("GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED=%v", utils.Config.Auth.MFA.WebAuthn.EnrollEnabled), - fmt.Sprintf("GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED=%v", utils.Config.Auth.MFA.WebAuthn.VerifyEnabled), - fmt.Sprintf("GOTRUE_MFA_MAX_ENROLLED_FACTORS=%v", utils.Config.Auth.MFA.MaxEnrolledFactors), - - // Add rate limit configurations - fmt.Sprintf("GOTRUE_RATE_LIMIT_ANONYMOUS_USERS=%v", utils.Config.Auth.RateLimit.AnonymousUsers), - fmt.Sprintf("GOTRUE_RATE_LIMIT_TOKEN_REFRESH=%v", utils.Config.Auth.RateLimit.TokenRefresh), - fmt.Sprintf("GOTRUE_RATE_LIMIT_OTP=%v", utils.Config.Auth.RateLimit.SignInSignUps), - fmt.Sprintf("GOTRUE_RATE_LIMIT_VERIFY=%v", utils.Config.Auth.RateLimit.TokenVerifications), - fmt.Sprintf("GOTRUE_RATE_LIMIT_SMS_SENT=%v", utils.Config.Auth.RateLimit.SmsSent), - fmt.Sprintf("GOTRUE_RATE_LIMIT_WEB3=%v", utils.Config.Auth.RateLimit.Web3), - } + env := buildGotrueEnv(dbConfig) // Serialise default or custom signing keys if keys, err := json.Marshal(utils.Config.Auth.SigningKeys); err == nil { @@ -817,26 +746,7 @@ EOF ) } - for name, config := range utils.Config.Auth.External { - env = append( - env, - fmt.Sprintf("GOTRUE_EXTERNAL_%s_ENABLED=%v", strings.ToUpper(name), config.Enabled), - fmt.Sprintf("GOTRUE_EXTERNAL_%s_CLIENT_ID=%s", strings.ToUpper(name), config.ClientId), - fmt.Sprintf("GOTRUE_EXTERNAL_%s_SECRET=%s", strings.ToUpper(name), config.Secret.Value), - fmt.Sprintf("GOTRUE_EXTERNAL_%s_SKIP_NONCE_CHECK=%t", strings.ToUpper(name), config.SkipNonceCheck), - fmt.Sprintf("GOTRUE_EXTERNAL_%s_EMAIL_OPTIONAL=%t", strings.ToUpper(name), config.EmailOptional), - ) - - redirectUri := config.RedirectUri - if redirectUri == "" { - redirectUri = utils.Config.Auth.JwtIssuer + "/callback" - } - env = append(env, fmt.Sprintf("GOTRUE_EXTERNAL_%s_REDIRECT_URI=%s", strings.ToUpper(name), redirectUri)) - - if config.Url != "" { - env = append(env, fmt.Sprintf("GOTRUE_EXTERNAL_%s_URL=%s", strings.ToUpper(name), config.Url)) - } - } + env = appendGotrueExternalProviderEnv(env) env = append(env, fmt.Sprintf("GOTRUE_EXTERNAL_WEB3_SOLANA_ENABLED=%v", utils.Config.Auth.Web3.Solana.Enabled), fmt.Sprintf("GOTRUE_EXTERNAL_WEB3_ETHEREUM_ENABLED=%v", utils.Config.Auth.Web3.Ethereum.Enabled), @@ -1368,6 +1278,100 @@ func formatMapForEnvConfig(input map[string]string, output *bytes.Buffer) { } } +func buildGotrueEnv(dbConfig pgconn.Config) []string { + var testOTP bytes.Buffer + if len(utils.Config.Auth.Sms.TestOTP) > 0 { + formatMapForEnvConfig(utils.Config.Auth.Sms.TestOTP, &testOTP) + } + + return []string{ + "API_EXTERNAL_URL=" + utils.Config.AuthExternalURL(), + + "GOTRUE_API_HOST=0.0.0.0", + "GOTRUE_API_PORT=9999", + + "GOTRUE_DB_DRIVER=postgres", + fmt.Sprintf("GOTRUE_DB_DATABASE_URL=postgresql://supabase_auth_admin:%s@%s:%d/%s", dbConfig.Password, dbConfig.Host, dbConfig.Port, dbConfig.Database), + + "GOTRUE_SITE_URL=" + utils.Config.Auth.SiteUrl, + "GOTRUE_URI_ALLOW_LIST=" + strings.Join(utils.Config.Auth.AdditionalRedirectUrls, ","), + fmt.Sprintf("GOTRUE_DISABLE_SIGNUP=%v", !utils.Config.Auth.EnableSignup), + + "GOTRUE_JWT_ADMIN_ROLES=service_role", + "GOTRUE_JWT_AUD=authenticated", + "GOTRUE_JWT_DEFAULT_GROUP_NAME=authenticated", + fmt.Sprintf("GOTRUE_JWT_EXP=%v", utils.Config.Auth.JwtExpiry), + "GOTRUE_JWT_SECRET=" + utils.Config.Auth.JwtSecret.Value, + "GOTRUE_JWT_ISSUER=" + utils.Config.Auth.JwtIssuer, + + fmt.Sprintf("GOTRUE_EXTERNAL_EMAIL_ENABLED=%v", utils.Config.Auth.Email.EnableSignup), + fmt.Sprintf("GOTRUE_MAILER_SECURE_EMAIL_CHANGE_ENABLED=%v", utils.Config.Auth.Email.DoubleConfirmChanges), + fmt.Sprintf("GOTRUE_MAILER_AUTOCONFIRM=%v", !utils.Config.Auth.Email.EnableConfirmations), + fmt.Sprintf("GOTRUE_MAILER_OTP_LENGTH=%v", utils.Config.Auth.Email.OtpLength), + fmt.Sprintf("GOTRUE_MAILER_OTP_EXP=%v", utils.Config.Auth.Email.OtpExpiry), + "GOTRUE_MAILER_TEMPLATE_RELOADING_ENABLED=true", + + fmt.Sprintf("GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=%v", utils.Config.Auth.EnableAnonymousSignIns), + + fmt.Sprintf("GOTRUE_SMTP_MAX_FREQUENCY=%v", utils.Config.Auth.Email.MaxFrequency), + + "GOTRUE_MAILER_URLPATHS_INVITE=/verify", + "GOTRUE_MAILER_URLPATHS_CONFIRMATION=/verify", + "GOTRUE_MAILER_URLPATHS_RECOVERY=/verify", + "GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE=/verify", + "GOTRUE_RATE_LIMIT_EMAIL_SENT=360000", + + fmt.Sprintf("GOTRUE_EXTERNAL_PHONE_ENABLED=%v", utils.Config.Auth.Sms.EnableSignup), + fmt.Sprintf("GOTRUE_SMS_AUTOCONFIRM=%v", !utils.Config.Auth.Sms.EnableConfirmations), + fmt.Sprintf("GOTRUE_SMS_MAX_FREQUENCY=%v", utils.Config.Auth.Sms.MaxFrequency), + "GOTRUE_SMS_OTP_EXP=6000", + "GOTRUE_SMS_OTP_LENGTH=6", + fmt.Sprintf("GOTRUE_SMS_TEMPLATE=%v", utils.Config.Auth.Sms.Template), + "GOTRUE_SMS_TEST_OTP=" + testOTP.String(), + + fmt.Sprintf("GOTRUE_PASSWORD_MIN_LENGTH=%v", utils.Config.Auth.MinimumPasswordLength), + fmt.Sprintf("GOTRUE_PASSWORD_REQUIRED_CHARACTERS=%v", utils.Config.Auth.PasswordRequirements.ToChar()), + fmt.Sprintf("GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED=%v", utils.Config.Auth.EnableRefreshTokenRotation), + fmt.Sprintf("GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL=%v", utils.Config.Auth.RefreshTokenReuseInterval), + fmt.Sprintf("GOTRUE_SECURITY_MANUAL_LINKING_ENABLED=%v", utils.Config.Auth.EnableManualLinking), + fmt.Sprintf("GOTRUE_SECURITY_UPDATE_PASSWORD_REQUIRE_REAUTHENTICATION=%v", utils.Config.Auth.Email.SecurePasswordChange), + fmt.Sprintf("GOTRUE_MFA_PHONE_ENROLL_ENABLED=%v", utils.Config.Auth.MFA.Phone.EnrollEnabled), + fmt.Sprintf("GOTRUE_MFA_PHONE_VERIFY_ENABLED=%v", utils.Config.Auth.MFA.Phone.VerifyEnabled), + fmt.Sprintf("GOTRUE_MFA_TOTP_ENROLL_ENABLED=%v", utils.Config.Auth.MFA.TOTP.EnrollEnabled), + fmt.Sprintf("GOTRUE_MFA_TOTP_VERIFY_ENABLED=%v", utils.Config.Auth.MFA.TOTP.VerifyEnabled), + fmt.Sprintf("GOTRUE_MFA_WEB_AUTHN_ENROLL_ENABLED=%v", utils.Config.Auth.MFA.WebAuthn.EnrollEnabled), + fmt.Sprintf("GOTRUE_MFA_WEB_AUTHN_VERIFY_ENABLED=%v", utils.Config.Auth.MFA.WebAuthn.VerifyEnabled), + fmt.Sprintf("GOTRUE_MFA_MAX_ENROLLED_FACTORS=%v", utils.Config.Auth.MFA.MaxEnrolledFactors), + + fmt.Sprintf("GOTRUE_RATE_LIMIT_ANONYMOUS_USERS=%v", utils.Config.Auth.RateLimit.AnonymousUsers), + fmt.Sprintf("GOTRUE_RATE_LIMIT_TOKEN_REFRESH=%v", utils.Config.Auth.RateLimit.TokenRefresh), + fmt.Sprintf("GOTRUE_RATE_LIMIT_OTP=%v", utils.Config.Auth.RateLimit.SignInSignUps), + fmt.Sprintf("GOTRUE_RATE_LIMIT_VERIFY=%v", utils.Config.Auth.RateLimit.TokenVerifications), + fmt.Sprintf("GOTRUE_RATE_LIMIT_SMS_SENT=%v", utils.Config.Auth.RateLimit.SmsSent), + fmt.Sprintf("GOTRUE_RATE_LIMIT_WEB3=%v", utils.Config.Auth.RateLimit.Web3), + } +} + +func appendGotrueExternalProviderEnv(env []string) []string { + for name, config := range utils.Config.Auth.External { + env = append( + env, + fmt.Sprintf("GOTRUE_EXTERNAL_%s_ENABLED=%v", strings.ToUpper(name), config.Enabled), + fmt.Sprintf("GOTRUE_EXTERNAL_%s_CLIENT_ID=%s", strings.ToUpper(name), config.ClientId), + fmt.Sprintf("GOTRUE_EXTERNAL_%s_SECRET=%s", strings.ToUpper(name), config.Secret.Value), + fmt.Sprintf("GOTRUE_EXTERNAL_%s_SKIP_NONCE_CHECK=%t", strings.ToUpper(name), config.SkipNonceCheck), + fmt.Sprintf("GOTRUE_EXTERNAL_%s_EMAIL_OPTIONAL=%t", strings.ToUpper(name), config.EmailOptional), + ) + if config.RedirectUri != "" { + env = append(env, fmt.Sprintf("GOTRUE_EXTERNAL_%s_REDIRECT_URI=%s", strings.ToUpper(name), config.RedirectUri)) + } + if config.Url != "" { + env = append(env, fmt.Sprintf("GOTRUE_EXTERNAL_%s_URL=%s", strings.ToUpper(name), config.Url)) + } + } + return env +} + func printSecurityNotice() { fmt.Fprintln(os.Stderr, utils.Yellow("Local dev security notice")) fmt.Fprintln(os.Stderr, "All services bind to 0.0.0.0 (network-accessible, not just localhost)") diff --git a/internal/start/start_test.go b/internal/start/start_test.go index 56d237e2b9..573c28b092 100644 --- a/internal/start/start_test.go +++ b/internal/start/start_test.go @@ -6,6 +6,7 @@ import ( "errors" "net/http" "regexp" + "strings" "testing" "time" @@ -300,6 +301,59 @@ func TestDatabaseStart(t *testing.T) { }) } +func TestBuildGotrueEnv(t *testing.T) { + original := utils.Config + t.Cleanup(func() { + utils.Config = original + }) + + t.Run("uses auth scoped external url and relative mailer paths", func(t *testing.T) { + utils.Config = config.NewConfig() + utils.Config.Api.ExternalUrl = "http://127.0.0.1:54321" + utils.Config.Auth.ExternalUrl = "http://127.0.0.1:54321/auth/v1" + utils.Config.Auth.JwtIssuer = utils.Config.Auth.ExternalUrl + utils.Config.Auth.SiteUrl = "http://127.0.0.1:3000" + provider := utils.Config.Auth.External["github"] + provider.Enabled = true + provider.ClientId = "client-id" + provider.Secret.Value = "secret" + utils.Config.Auth.External["github"] = provider + + env := envToMap(appendGotrueExternalProviderEnv(buildGotrueEnv(pgconn.Config{ + Host: "db", + Port: 5432, + Database: "postgres", + Password: "postgres", + }))) + + assert.Equal(t, "http://127.0.0.1:54321/auth/v1", env["API_EXTERNAL_URL"]) + assert.Equal(t, "http://127.0.0.1:54321/auth/v1", env["GOTRUE_JWT_ISSUER"]) + assert.Equal(t, "/verify", env["GOTRUE_MAILER_URLPATHS_INVITE"]) + assert.Equal(t, "/verify", env["GOTRUE_MAILER_URLPATHS_CONFIRMATION"]) + assert.Equal(t, "/verify", env["GOTRUE_MAILER_URLPATHS_RECOVERY"]) + assert.Equal(t, "/verify", env["GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE"]) + assert.NotContains(t, env, "GOTRUE_EXTERNAL_GITHUB_REDIRECT_URI") + }) + + t.Run("preserves explicit provider redirect override", func(t *testing.T) { + utils.Config = config.NewConfig() + utils.Config.Api.ExternalUrl = "http://127.0.0.1:54321" + utils.Config.Auth.ExternalUrl = "http://127.0.0.1:54321/auth/v1" + utils.Config.Auth.JwtIssuer = "https://issuer.example.com/auth/v1" + utils.Config.Auth.SiteUrl = "http://127.0.0.1:3000" + provider := utils.Config.Auth.External["azure"] + provider.Enabled = true + provider.RedirectUri = "https://example.com/custom/callback" + utils.Config.Auth.External["azure"] = provider + + env := envToMap(appendGotrueExternalProviderEnv(buildGotrueEnv(pgconn.Config{}))) + + assert.Equal(t, "http://127.0.0.1:54321/auth/v1", env["API_EXTERNAL_URL"]) + assert.Equal(t, "https://issuer.example.com/auth/v1", env["GOTRUE_JWT_ISSUER"]) + assert.Equal(t, "https://example.com/custom/callback", env["GOTRUE_EXTERNAL_AZURE_REDIRECT_URI"]) + }) +} + func TestFormatMapForEnvConfig(t *testing.T) { t.Run("It produces the correct format and removes the trailing comma", func(t *testing.T) { testcases := []struct { @@ -347,3 +401,14 @@ func TestFormatMapForEnvConfig(t *testing.T) { } }) } + +func envToMap(env []string) map[string]string { + result := make(map[string]string, len(env)) + for _, item := range env { + key, value, ok := strings.Cut(item, "=") + if ok { + result[key] = value + } + } + return result +} diff --git a/internal/start/templates/kong.yml b/internal/start/templates/kong.yml index 0c185eb6e4..4cbfc3b1eb 100644 --- a/internal/start/templates/kong.yml +++ b/internal/start/templates/kong.yml @@ -2,7 +2,7 @@ _format_version: "1.1" services: # Tenant project endpoints - name: auth-v1-open - _comment: "GoTrue: /auth/v1/verify* -> http://auth:9999/verify*" + _comment: "GoTrue external /auth/v1/verify* -> internal root /verify*" url: http://{{ .GotrueId }}:9999/verify routes: - name: auth-v1-open @@ -12,7 +12,7 @@ services: plugins: - name: cors - name: auth-v1-open-callback - _comment: "GoTrue: /auth/v1/callback* -> http://auth:9999/callback*" + _comment: "GoTrue external /auth/v1/callback* -> internal root /callback*" url: http://{{ .GotrueId }}:9999/callback routes: - name: auth-v1-open-callback @@ -22,7 +22,7 @@ services: plugins: - name: cors - name: auth-v1-open-authorize - _comment: "GoTrue: /auth/v1/authorize* -> http://auth:9999/authorize*" + _comment: "GoTrue external /auth/v1/authorize* -> internal root /authorize*" url: http://{{ .GotrueId }}:9999/authorize routes: - name: auth-v1-open-authorize @@ -32,7 +32,7 @@ services: plugins: - name: cors - name: auth-v1 - _comment: "GoTrue: /auth/v1/* -> http://auth:9999/*" + _comment: "GoTrue external /auth/v1/* -> internal root /*" url: http://{{ .GotrueId }}:9999/ routes: - name: auth-v1-all diff --git a/internal/telemetry/state.go b/internal/telemetry/state.go index 58096c5de4..8d69996468 100644 --- a/internal/telemetry/state.go +++ b/internal/telemetry/state.go @@ -17,6 +17,12 @@ const SchemaVersion = 1 const sessionRotationThreshold = 30 * time.Minute +// errMalformedState marks any read where the file existed but couldn't be +// decoded into a State — covers JSON syntax errors, unexpected types +// (e.g. session_last_active stored as a number), and field-level unmarshal +// failures from time.Time / uuid. Used to trigger fresh-state creation. +var errMalformedState = errors.New("malformed telemetry state") + type State struct { Enabled bool `json:"enabled"` DeviceID string `json:"device_id"` @@ -48,7 +54,7 @@ func LoadState(fsys afero.Fs) (State, error) { } var state State if err := json.Unmarshal(contents, &state); err != nil { - return State{}, errors.Errorf("failed to parse telemetry file: %w", err) + return State{}, errors.Errorf("%w: %v", errMalformedState, err) } return state, nil } @@ -74,7 +80,11 @@ func LoadOrCreateState(fsys afero.Fs, now time.Time) (State, bool, error) { state.SessionLastActive = now.UTC() return state, false, SaveState(state, fsys) } - if !errors.Is(err, os.ErrNotExist) { + // Treat a missing file OR an unparseable file as "no existing state" and + // recreate. Identity fields (device_id, session_id) are not worth + // surfacing an error for — losing them is harmless. We only propagate + // genuine I/O errors (permissions, disk full) so the user can act. + if !errors.Is(err, os.ErrNotExist) && !errors.Is(err, errMalformedState) { return State{}, false, err } state = State{ diff --git a/internal/telemetry/state_test.go b/internal/telemetry/state_test.go index a2a07a5b35..9cd03dd967 100644 --- a/internal/telemetry/state_test.go +++ b/internal/telemetry/state_test.go @@ -79,6 +79,38 @@ func TestLoadOrCreateState(t *testing.T) { assert.Equal(t, now, state.SessionLastActive) }) + t.Run("recovers from corrupted state file", func(t *testing.T) { + // Each entry simulates a real-world corruption shape we've observed. + corruptions := map[string][]byte{ + "empty file": {}, + "truncated json": []byte(`{"enabled":tru`), + "session_last_active is a number (not a string)": []byte(`{"enabled":true,"device_id":"d","session_id":"s","session_last_active":1776770348993,"schema_version":1}`), + "session_last_active is a malformed string": []byte(`{"enabled":true,"device_id":"d","session_id":"s","session_last_active":"not-a-time","schema_version":1}`), + } + for label, contents := range corruptions { + t.Run(label, func(t *testing.T) { + t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") + fsys := afero.NewMemMapFs() + path, err := telemetryPath() + require.NoError(t, err) + require.NoError(t, fsys.MkdirAll("/tmp/supabase-home", 0755)) + require.NoError(t, afero.WriteFile(fsys, path, contents, 0644)) + + state, created, err := LoadOrCreateState(fsys, now) + + require.NoError(t, err) + assert.True(t, created) + assert.True(t, state.Enabled) + assert.Equal(t, SchemaVersion, state.SchemaVersion) + assert.NoError(t, uuid.Validate(state.DeviceID)) + assert.NoError(t, uuid.Validate(state.SessionID)) + saved, err := LoadState(fsys) + require.NoError(t, err) + assert.Equal(t, state, saved) + }) + } + }) + t.Run("rotates stale session after inactivity threshold", func(t *testing.T) { t.Setenv("SUPABASE_HOME", "/tmp/supabase-home") fsys := afero.NewMemMapFs() diff --git a/internal/utils/agent/agent.go b/internal/utils/agent/agent.go index 65846ea62a..e3b0d7f88f 100644 --- a/internal/utils/agent/agent.go +++ b/internal/utils/agent/agent.go @@ -5,6 +5,11 @@ import ( "strings" ) +// IsClaudeCode reports whether the CLI is running inside Claude Code. +func IsClaudeCode() bool { + return os.Getenv("CLAUDECODE") != "" || os.Getenv("CLAUDE_CODE") != "" +} + // IsAgent checks environment variables to detect if the CLI is being invoked // by an AI coding agent. Based on the detection logic from Vercel's // @vercel/functions/ai package. @@ -37,7 +42,7 @@ func IsAgent() bool { return true } // Claude Code - if os.Getenv("CLAUDECODE") != "" || os.Getenv("CLAUDE_CODE") != "" { + if IsClaudeCode() { return true } // Replit diff --git a/internal/utils/misc.go b/internal/utils/misc.go index 937519aa53..7ba482d961 100644 --- a/internal/utils/misc.go +++ b/internal/utils/misc.go @@ -17,6 +17,7 @@ import ( "github.com/go-git/go-git/v5/plumbing/format/gitignore" "github.com/spf13/afero" "github.com/spf13/viper" + "github.com/supabase/cli/internal/utils/agent" "github.com/supabase/cli/pkg/migration" ) @@ -38,6 +39,15 @@ func ShortContainerImageName(imageName string) string { const SuggestDebugFlag = "Try rerunning the command with --debug to troubleshoot the error." +const claudeCodeHint = `` + +func SuggestClaudePlugin() string { + if agent.IsClaudeCode() { + return claudeCodeHint + } + return "" +} + var ( CmdSuggestion string CurrentDirAbs string @@ -76,6 +86,7 @@ var ( PgmetaVersionPath = filepath.Join(TempDir, "pgmeta-version") PoolerVersionPath = filepath.Join(TempDir, "pooler-version") RealtimeVersionPath = filepath.Join(TempDir, "realtime-version") + PgDeltaVersionPath = filepath.Join(TempDir, "pgdelta-version") CliVersionPath = filepath.Join(TempDir, "cli-latest") CurrBranchPath = filepath.Join(SupabaseDirPath, ".branches", "_current_branch") // DeclarativeDir is the canonical location for pg-delta declarative schema @@ -256,6 +267,12 @@ func ValidateFunctionSlug(slug string) error { } func GetHostname() string { + // Overrides the host for local service connections. Useful when + // running inside a dev container when the Docker host is not + // 127.0.0.1 (container's own loopback). + if h := os.Getenv("SUPABASE_SERVICES_HOSTNAME"); h != "" { + return h + } host := Docker.DaemonHost() if parsed, err := client.ParseHostURL(host); err == nil && parsed.Scheme == "tcp" { if host, _, err := net.SplitHostPort(parsed.Host); err == nil { diff --git a/internal/utils/misc_test.go b/internal/utils/misc_test.go index 0e1b6475d7..e8e075201b 100644 --- a/internal/utils/misc_test.go +++ b/internal/utils/misc_test.go @@ -157,6 +157,18 @@ func TestAssertProjectRefIsValid(t *testing.T) { }) } +func TestGetHostname(t *testing.T) { + t.Run("returns SUPABASE_SERVICES_HOSTNAME when set", func(t *testing.T) { + t.Setenv("SUPABASE_SERVICES_HOSTNAME", "host.docker.internal") + assert.Equal(t, "host.docker.internal", GetHostname()) + }) + + t.Run("returns 127.0.0.1 when SUPABASE_SERVICES_HOSTNAME is not set", func(t *testing.T) { + t.Setenv("SUPABASE_SERVICES_HOSTNAME", "") + assert.Equal(t, "127.0.0.1", GetHostname()) + }) +} + func TestWriteFile(t *testing.T) { t.Run("writes file with directories", func(t *testing.T) { fsys := afero.NewMemMapFs() diff --git a/pkg/api/client.gen.go b/pkg/api/client.gen.go index 5d7941a128..ed33fb4585 100644 --- a/pkg/api/client.gen.go +++ b/pkg/api/client.gen.go @@ -14260,7 +14260,7 @@ func (r V1GetServicesHealthResponse) StatusCode() int { type V1GetJitAccessConfigResponse struct { Body []byte HTTPResponse *http.Response - JSON200 *JitAccessResponse + JSON200 *JitStateResponse } // Status returns HTTPResponse.Status @@ -14282,7 +14282,7 @@ func (r V1GetJitAccessConfigResponse) StatusCode() int { type V1UpdateJitAccessConfigResponse struct { Body []byte HTTPResponse *http.Response - JSON200 *JitAccessResponse + JSON200 *JitStateResponse } // Status returns HTTPResponse.Status @@ -19925,7 +19925,7 @@ func ParseV1GetJitAccessConfigResponse(rsp *http.Response) (*V1GetJitAccessConfi switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest JitAccessResponse + var dest JitStateResponse if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } @@ -19951,7 +19951,7 @@ func ParseV1UpdateJitAccessConfigResponse(rsp *http.Response) (*V1UpdateJitAcces switch { case strings.Contains(rsp.Header.Get("Content-Type"), "json") && rsp.StatusCode == 200: - var dest JitAccessResponse + var dest JitStateResponse if err := json.Unmarshal(bodyBytes, &dest); err != nil { return nil, err } diff --git a/pkg/api/types.gen.go b/pkg/api/types.gen.go index 637dcad9b5..89211a3a2e 100644 --- a/pkg/api/types.gen.go +++ b/pkg/api/types.gen.go @@ -492,8 +492,26 @@ const ( // Defines values for JitAccessRequestRequestState. const ( - Disabled JitAccessRequestRequestState = "disabled" - Enabled JitAccessRequestRequestState = "enabled" + JitAccessRequestRequestStateDisabled JitAccessRequestRequestState = "disabled" + JitAccessRequestRequestStateEnabled JitAccessRequestRequestState = "enabled" +) + +// Defines values for JitStateResponse0State. +const ( + JitStateResponse0StateDisabled JitStateResponse0State = "disabled" + JitStateResponse0StateEnabled JitStateResponse0State = "enabled" +) + +// Defines values for JitStateResponse1State. +const ( + Unavailable JitStateResponse1State = "unavailable" +) + +// Defines values for JitStateResponse1UnavailableReason. +const ( + ManualMigrationRequired JitStateResponse1UnavailableReason = "manual_migration_required" + PostgresUpgradeRequired JitStateResponse1UnavailableReason = "postgres_upgrade_required" + TemporarilyUnavailable JitStateResponse1UnavailableReason = "temporarily_unavailable" ) // Defines values for ListActionRunResponseRunStepsName. @@ -2856,6 +2874,32 @@ type JitListAccessResponse struct { } `json:"items"` } +// JitStateResponse defines model for JitStateResponse. +type JitStateResponse struct { + union json.RawMessage +} + +// JitStateResponse0 defines model for . +type JitStateResponse0 struct { + AppliedSuccessfully *bool `json:"appliedSuccessfully,omitempty"` + State JitStateResponse0State `json:"state"` +} + +// JitStateResponse0State defines model for JitStateResponse.0.State. +type JitStateResponse0State string + +// JitStateResponse1 defines model for . +type JitStateResponse1 struct { + State JitStateResponse1State `json:"state"` + UnavailableReason JitStateResponse1UnavailableReason `json:"unavailableReason"` +} + +// JitStateResponse1State defines model for JitStateResponse.1.State. +type JitStateResponse1State string + +// JitStateResponse1UnavailableReason defines model for JitStateResponse.1.UnavailableReason. +type JitStateResponse1UnavailableReason string + // LegacyApiKeysResponse defines model for LegacyApiKeysResponse. type LegacyApiKeysResponse struct { Enabled bool `json:"enabled"` @@ -5938,6 +5982,68 @@ func (t *DiskResponse_Attributes) UnmarshalJSON(b []byte) error { return err } +// AsJitStateResponse0 returns the union data inside the JitStateResponse as a JitStateResponse0 +func (t JitStateResponse) AsJitStateResponse0() (JitStateResponse0, error) { + var body JitStateResponse0 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromJitStateResponse0 overwrites any union data inside the JitStateResponse as the provided JitStateResponse0 +func (t *JitStateResponse) FromJitStateResponse0(v JitStateResponse0) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeJitStateResponse0 performs a merge with any union data inside the JitStateResponse, using the provided JitStateResponse0 +func (t *JitStateResponse) MergeJitStateResponse0(v JitStateResponse0) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +// AsJitStateResponse1 returns the union data inside the JitStateResponse as a JitStateResponse1 +func (t JitStateResponse) AsJitStateResponse1() (JitStateResponse1, error) { + var body JitStateResponse1 + err := json.Unmarshal(t.union, &body) + return body, err +} + +// FromJitStateResponse1 overwrites any union data inside the JitStateResponse as the provided JitStateResponse1 +func (t *JitStateResponse) FromJitStateResponse1(v JitStateResponse1) error { + b, err := json.Marshal(v) + t.union = b + return err +} + +// MergeJitStateResponse1 performs a merge with any union data inside the JitStateResponse, using the provided JitStateResponse1 +func (t *JitStateResponse) MergeJitStateResponse1(v JitStateResponse1) error { + b, err := json.Marshal(v) + if err != nil { + return err + } + + merged, err := runtime.JSONMerge(t.union, b) + t.union = merged + return err +} + +func (t JitStateResponse) MarshalJSON() ([]byte, error) { + b, err := t.union.MarshalJSON() + return b, err +} + +func (t *JitStateResponse) UnmarshalJSON(b []byte) error { + err := t.union.UnmarshalJSON(b) + return err +} + // AsListProjectAddonsResponseAvailableAddonsVariantsId0 returns the union data inside the ListProjectAddonsResponse_AvailableAddons_Variants_Id as a ListProjectAddonsResponseAvailableAddonsVariantsId0 func (t ListProjectAddonsResponse_AvailableAddons_Variants_Id) AsListProjectAddonsResponseAvailableAddonsVariantsId0() (ListProjectAddonsResponseAvailableAddonsVariantsId0, error) { var body ListProjectAddonsResponseAvailableAddonsVariantsId0 diff --git a/pkg/config/auth.go b/pkg/config/auth.go index c1795c8971..4517affb5b 100644 --- a/pkg/config/auth.go +++ b/pkg/config/auth.go @@ -150,6 +150,7 @@ type ( Image string `toml:"-" json:"-"` SiteUrl string `toml:"site_url" json:"site_url"` + ExternalUrl string `toml:"external_url" json:"external_url"` AdditionalRedirectUrls []string `toml:"additional_redirect_urls" json:"additional_redirect_urls"` JwtExpiry uint `toml:"jwt_expiry" json:"jwt_expiry"` JwtIssuer string `toml:"jwt_issuer" json:"jwt_issuer"` @@ -397,6 +398,13 @@ type ( } ) +func (a auth) GetExternalURL(apiExternalURL string) string { + if len(a.ExternalUrl) > 0 { + return a.ExternalUrl + } + return strings.TrimRight(apiExternalURL, "/") + "/auth/v1" +} + func (a *auth) ToUpdateAuthConfigBody() v1API.UpdateAuthConfigBody { body := v1API.UpdateAuthConfigBody{ SiteUrl: nullable.NewNullableWithValue(a.SiteUrl), diff --git a/pkg/config/config.go b/pkg/config/config.go index d17b894416..e2697ad825 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -229,6 +229,8 @@ type ( Enabled bool `toml:"enabled" json:"enabled"` DeclarativeSchemaPath string `toml:"declarative_schema_path" json:"declarative_schema_path"` FormatOptions string `toml:"format_options" json:"format_options"` + // NpmVersion is set from .temp/pgdelta-version during Load (not from TOML). + NpmVersion string `toml:"-" json:"-"` } inspect struct { @@ -445,6 +447,10 @@ func NewConfig(editors ...ConfigEditor) config { return initial } +func (c *config) AuthExternalURL() string { + return c.Auth.GetExternalURL(c.Api.ExternalUrl) +} + var ( //go:embed templates/certs/kong.local.crt kongCert []byte @@ -632,9 +638,12 @@ func (c *config) Load(path string, fsys fs.FS, overrides ...ConfigEditor) error } c.Api.ExternalUrl = apiUrl.String() } + if len(c.Auth.ExternalUrl) == 0 { + c.Auth.ExternalUrl = c.AuthExternalURL() + } // Set default JWT issuer if not configured if len(c.Auth.JwtIssuer) == 0 { - c.Auth.JwtIssuer = c.Api.ExternalUrl + "/auth/v1" + c.Auth.JwtIssuer = c.Auth.ExternalUrl } // Update image versions switch c.Db.MajorVersion { @@ -683,6 +692,16 @@ func (c *config) Load(path string, fsys fs.FS, overrides ...ConfigEditor) error if version, err := fs.ReadFile(fsys, builder.LogflareVersionPath); err == nil && len(version) > 0 { c.Analytics.Image = replaceImageTag(Images.Logflare, string(version)) } + v := DefaultPgDeltaNpmVersion + if version, err := fs.ReadFile(fsys, builder.PgDeltaVersionPath); err == nil { + if trimmed := strings.TrimSpace(string(version)); len(trimmed) > 0 { + v = trimmed + } + } + if c.Experimental.PgDelta == nil { + c.Experimental.PgDelta = &PgDeltaConfig{} + } + c.Experimental.PgDelta.NpmVersion = v // TODO: replace derived config resolution with viper decode hooks if err := c.resolve(builder, fsys); err != nil { return err diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index f019b7cbce..d7bca3948d 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -39,14 +39,43 @@ func TestConfigParsing(t *testing.T) { assert.NoError(t, err) }) + t.Run("auth external url defaults from api external url", func(t *testing.T) { + config := NewConfig() + require.NoError(t, config.Load("", fs.MapFS{})) + + assert.Equal(t, strings.TrimRight(config.Api.ExternalUrl, "/")+"/auth/v1", config.Auth.ExternalUrl) + assert.Equal(t, config.Auth.ExternalUrl, config.Auth.JwtIssuer) + }) + + t.Run("auth external url and jwt issuer preserve explicit overrides", func(t *testing.T) { + config := NewConfig() + fsys := fs.MapFS{ + "config.toml": &fs.MapFile{Data: []byte(` +[api] +external_url = "https://api.example.com/" + +[auth] +site_url = "https://app.example.com" +external_url = "https://auth.example.com/custom/" +jwt_issuer = "https://issuer.example.com/custom/" +`)}, + } + + require.NoError(t, config.Load("config.toml", fsys)) + assert.Equal(t, "https://auth.example.com/custom/", config.Auth.ExternalUrl) + assert.Equal(t, "https://issuer.example.com/custom/", config.Auth.JwtIssuer) + }) + t.Run("config file with environment variables", func(t *testing.T) { config := NewConfig() // Setup in-memory fs fsys := fs.MapFS{ - "supabase/config.toml": &fs.MapFile{Data: testInitConfigEmbed}, - "supabase/templates/invite.html": &fs.MapFile{}, - "certs/my-cert.pem": &fs.MapFile{}, - "certs/my-key.pem": &fs.MapFile{}, + "supabase/config.toml": &fs.MapFile{Data: testInitConfigEmbed}, + "supabase/templates/invite.html": &fs.MapFile{}, + "supabase/templates/password_changed_notification.html": &fs.MapFile{}, + "supabase/signing_keys.json": &fs.MapFile{Data: []byte("[]")}, + "certs/my-cert.pem": &fs.MapFile{}, + "certs/my-key.pem": &fs.MapFile{}, } // Run test t.Setenv("TWILIO_AUTH_TOKEN", "token") @@ -214,6 +243,57 @@ format_options = "not-json" }) } +func TestPgDeltaNpmVersionPinning(t *testing.T) { + t.Run("defaults when pgdelta-version file missing", func(t *testing.T) { + c := NewConfig() + require.NoError(t, c.Load("", fs.MapFS{})) + require.NotNil(t, c.Experimental.PgDelta) + assert.Equal(t, DefaultPgDeltaNpmVersion, c.Experimental.PgDelta.NpmVersion) + assert.Equal(t, DefaultPgDeltaNpmVersion, EffectivePgDeltaNpmVersion(Config(&c))) + }) + + t.Run("EffectivePgDeltaNpmVersion nil config uses default", func(t *testing.T) { + assert.Equal(t, DefaultPgDeltaNpmVersion, EffectivePgDeltaNpmVersion(nil)) + }) + + t.Run("reads trimmed version from supabase/.temp/pgdelta-version", func(t *testing.T) { + c := NewConfig() + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(` +[experimental.pgdelta] +enabled = true +`)}, + "supabase/.temp/pgdelta-version": &fs.MapFile{Data: []byte(" 9.9.9-test \n")}, + } + require.NoError(t, c.Load("", fsys)) + require.NotNil(t, c.Experimental.PgDelta) + assert.Equal(t, "9.9.9-test", c.Experimental.PgDelta.NpmVersion) + assert.Equal(t, "9.9.9-test", EffectivePgDeltaNpmVersion(Config(&c))) + }) + + t.Run("whitespace-only pgdelta-version keeps default", func(t *testing.T) { + c := NewConfig() + fsys := fs.MapFS{ + "supabase/config.toml": &fs.MapFile{Data: []byte(` +[experimental.pgdelta] +enabled = true +`)}, + "supabase/.temp/pgdelta-version": &fs.MapFile{Data: []byte(" \n")}, + } + require.NoError(t, c.Load("", fsys)) + require.NotNil(t, c.Experimental.PgDelta) + assert.Equal(t, DefaultPgDeltaNpmVersion, c.Experimental.PgDelta.NpmVersion) + }) + + t.Run("InterpolatePgDeltaScript substitutes placeholder", func(t *testing.T) { + c := NewConfig() + require.NoError(t, c.Load("", fs.MapFS{})) + // Embedded TS pins use this semver literal before InterpolatePgDeltaScript runs. + got := InterpolatePgDeltaScript(Config(&c), `from "npm:@supabase/pg-delta@1.0.0-alpha.20";`) + assert.Equal(t, `from "npm:@supabase/pg-delta@`+DefaultPgDeltaNpmVersion+`";`, got) + }) +} + func TestRemoteOverride(t *testing.T) { t.Run("load staging override", func(t *testing.T) { config := NewConfig() diff --git a/pkg/config/pgdelta_version.go b/pkg/config/pgdelta_version.go new file mode 100644 index 0000000000..297a4c34b1 --- /dev/null +++ b/pkg/config/pgdelta_version.go @@ -0,0 +1,28 @@ +package config + +import "strings" + +// DefaultPgDeltaNpmVersion is the npm dist-tag/version used for @supabase/pg-delta +// when supabase/.temp/pgdelta-version is absent or empty. +const DefaultPgDeltaNpmVersion = "1.0.0-alpha.22" + +const pgDeltaNpmVersionPlaceholder = "1.0.0-alpha.20" + +// EffectivePgDeltaNpmVersion returns the pg-delta npm version from loaded config, +// or DefaultPgDeltaNpmVersion when unset (e.g. before Load or empty field). +func EffectivePgDeltaNpmVersion(c Config) string { + if c == nil { + return DefaultPgDeltaNpmVersion + } + if c.Experimental.PgDelta != nil { + if v := strings.TrimSpace(c.Experimental.PgDelta.NpmVersion); v != "" { + return v + } + } + return DefaultPgDeltaNpmVersion +} + +// InterpolatePgDeltaScript substitutes pg delta npm version placeholders in embedded TS. +func InterpolatePgDeltaScript(c Config, script string) string { + return strings.ReplaceAll(script, pgDeltaNpmVersionPlaceholder, EffectivePgDeltaNpmVersion(c)) +} diff --git a/pkg/config/templates/Dockerfile b/pkg/config/templates/Dockerfile index fb3225da8f..99efdf2cf8 100644 --- a/pkg/config/templates/Dockerfile +++ b/pkg/config/templates/Dockerfile @@ -5,7 +5,7 @@ FROM library/kong:2.8.1 AS kong FROM axllent/mailpit:v1.22.3 AS mailpit FROM postgrest/postgrest:v14.10 AS postgrest FROM supabase/postgres-meta:v0.96.4 AS pgmeta -FROM supabase/studio:2026.04.27-sha-4afbe9c AS studio +FROM supabase/studio:2026.04.28-sha-89d08a2 AS studio FROM darthsim/imgproxy:v3.8.0 AS imgproxy FROM supabase/edge-runtime:v1.73.13 AS edgeruntime FROM timberio/vector:0.53.0-alpine AS vector diff --git a/pkg/config/templates/config.toml b/pkg/config/templates/config.toml index 97ed4e5665..d34c36c9ba 100644 --- a/pkg/config/templates/config.toml +++ b/pkg/config/templates/config.toml @@ -152,11 +152,13 @@ enabled = true # The base URL of your website. Used as an allow-list for redirects and for constructing URLs used # in emails. site_url = "http://127.0.0.1:3000" +# The public URL that Auth serves on. Defaults to the API external URL with `/auth/v1` appended. +# external_url = "" # A list of *exact* URLs that auth providers are permitted to redirect to post authentication. additional_redirect_urls = ["https://127.0.0.1:3000"] # How long tokens are valid for, in seconds. Defaults to 3600 (1 hour), maximum 604,800 (1 week). jwt_expiry = 3600 -# JWT issuer URL. If not set, defaults to the local API URL (http://127.0.0.1:/auth/v1). +# JWT issuer URL. If not set, defaults to auth.external_url. # jwt_issuer = "" # Path to JWT signing key. DO NOT commit your signing keys file to git. # signing_keys_path = "./signing_keys.json" @@ -317,7 +319,7 @@ enabled = false client_id = "" # DO NOT commit your OAuth provider secret to git. Use environment variable substitution instead: secret = "env(SUPABASE_AUTH_EXTERNAL_APPLE_SECRET)" -# Overrides the default auth redirectUrl. +# Overrides the default auth callback URL derived from auth.external_url. redirect_uri = "" # Overrides the default auth provider URL. Used to support self-hosted gitlab, single-tenant Azure, # or any other third-party OIDC providers. diff --git a/pkg/config/testdata/TestAuthDiff/local_enabled_and_disabled.diff b/pkg/config/testdata/TestAuthDiff/local_enabled_and_disabled.diff index 5425d12659..21094fdd40 100644 --- a/pkg/config/testdata/TestAuthDiff/local_enabled_and_disabled.diff +++ b/pkg/config/testdata/TestAuthDiff/local_enabled_and_disabled.diff @@ -1,11 +1,16 @@ diff remote[auth] local[auth] --- remote[auth] +++ local[auth] -@@ -1,14 +1,14 @@ +@@ -1,16 +1,16 @@ enabled = false -site_url = "" ++site_url = "http://127.0.0.1:3000" + external_url = "" -additional_redirect_urls = ["https://127.0.0.1:3000", "https://ref.supabase.co"] -jwt_expiry = 0 ++additional_redirect_urls = ["https://127.0.0.1:3000"] ++jwt_expiry = 3600 + jwt_issuer = "" -enable_refresh_token_rotation = true -refresh_token_reuse_interval = 0 -enable_manual_linking = true @@ -13,9 +18,6 @@ diff remote[auth] local[auth] -enable_anonymous_sign_ins = true -minimum_password_length = 8 -password_requirements = "letters_digits" -+site_url = "http://127.0.0.1:3000" -+additional_redirect_urls = ["https://127.0.0.1:3000"] -+jwt_expiry = 3600 +enable_refresh_token_rotation = false +refresh_token_reuse_interval = 10 +enable_manual_linking = false @@ -23,6 +25,6 @@ diff remote[auth] local[auth] +enable_anonymous_sign_ins = false +minimum_password_length = 6 +password_requirements = "lower_upper_letters_digits_symbols" - jwt_secret = "" - anon_key = "" - service_role_key = "" + signing_keys_path = "" + publishable_key = "" + secret_key = "" diff --git a/pkg/config/utils.go b/pkg/config/utils.go index 2bb6db6f4b..4c004d4eeb 100644 --- a/pkg/config/utils.go +++ b/pkg/config/utils.go @@ -28,6 +28,7 @@ type pathBuilder struct { RealtimeVersionPath string EdgeRuntimeVersionPath string LogflareVersionPath string + PgDeltaVersionPath string CliVersionPath string CurrBranchPath string SchemasDir string @@ -64,6 +65,7 @@ func NewPathBuilder(configPath string) pathBuilder { PoolerVersionPath: filepath.Join(base, ".temp", "pooler-version"), RealtimeVersionPath: filepath.Join(base, ".temp", "realtime-version"), LogflareVersionPath: filepath.Join(base, ".temp", "logflare-version"), + PgDeltaVersionPath: filepath.Join(base, ".temp", "pgdelta-version"), CliVersionPath: filepath.Join(base, ".temp", "cli-latest"), CurrBranchPath: filepath.Join(base, ".branches", "_current_branch"), SchemasDir: filepath.Join(base, "schemas"),