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"),