diff --git a/apps/cli-go/internal/branches/get/get_test.go b/apps/cli-go/internal/branches/get/get_test.go index 7afd11cce7..0e9b54a15e 100644 --- a/apps/cli-go/internal/branches/get/get_test.go +++ b/apps/cli-go/internal/branches/get/get_test.go @@ -119,6 +119,50 @@ SUPABASE_URL = "https://%s." assert.NoError(t, err) }) + t.Run("encodes publishable key for new-format api keys", func(t *testing.T) { + t.Cleanup(fstest.MockStdout(t, fmt.Sprintf(`POSTGRES_URL = "postgresql://postgres:postgres@127.0.0.1:6543/postgres?connect_timeout=10" +POSTGRES_URL_NON_POOLING = "postgresql://postgres:postgres@127.0.0.1:5432/postgres?connect_timeout=10" +SUPABASE_DEFAULT_KEY = "sb_secret_test" +SUPABASE_JWT_SECRET = "secret-key" +SUPABASE_PUBLISHABLE_KEY = "sb_publishable_test" +SUPABASE_URL = "https://%s." +`, flags.ProjectRef))) + t.Cleanup(apitest.MockPlatformAPI(t)) + gock.New(utils.DefaultApiHost). + Get("/v1/branches/" + flags.ProjectRef). + Reply(http.StatusOK). + JSON(api.BranchDetailResponse{ + DbHost: "127.0.0.1", + DbPort: 5432, + DbUser: cast.Ptr("postgres"), + DbPass: cast.Ptr("postgres"), + JwtSecret: cast.Ptr("secret-key"), + Ref: flags.ProjectRef, + }) + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + flags.ProjectRef + "/api-keys"). + Reply(http.StatusOK). + JSON([]api.ApiKeyResponse{{ + Name: "default", + Type: nullable.NewNullableWithValue(api.ApiKeyResponseTypePublishable), + ApiKey: nullable.NewNullableWithValue("sb_publishable_test"), + }, { + Name: "default", + Type: nullable.NewNullableWithValue(api.ApiKeyResponseTypeSecret), + ApiKey: nullable.NewNullableWithValue("sb_secret_test"), + }}) + gock.New(utils.DefaultApiHost). + Get("/v1/projects/" + flags.ProjectRef + "/config/database/pooler"). + Reply(http.StatusOK). + JSON([]api.SupavisorConfigResponse{{ + ConnectionString: "postgres://postgres:postgres@127.0.0.1:6543/postgres", + DatabaseType: api.SupavisorConfigResponseDatabaseTypePRIMARY, + PoolMode: api.SupavisorConfigResponsePoolModeTransaction, + }}) + err := Run(context.Background(), flags.ProjectRef, nil) + assert.NoError(t, err) + }) + t.Run("throws error on network error", func(t *testing.T) { errNetwork := errors.New("network error") t.Cleanup(apitest.MockPlatformAPI(t)) diff --git a/apps/cli-go/internal/projects/apiKeys/api_keys.go b/apps/cli-go/internal/projects/apiKeys/api_keys.go index 0161273a42..60a23cd479 100644 --- a/apps/cli-go/internal/projects/apiKeys/api_keys.go +++ b/apps/cli-go/internal/projects/apiKeys/api_keys.go @@ -51,13 +51,22 @@ func RunGetApiKeys(ctx context.Context, projectRef string) ([]api.ApiKeyResponse func ToEnv(keys []api.ApiKeyResponse) map[string]string { envs := make(map[string]string, len(keys)) for _, entry := range keys { - name := strings.ToUpper(entry.Name) - key := fmt.Sprintf("SUPABASE_%s_KEY", name) + key := fmt.Sprintf("SUPABASE_%s_KEY", envSuffix(entry)) envs[key] = toValue(entry.ApiKey) } return envs } +// envSuffix maps an API key to the middle part of SUPABASE__KEY. +// Publishable keys named "default" become PUBLISHABLE (not DEFAULT) to avoid +// colliding with the default secret key. +func envSuffix(entry api.ApiKeyResponse) string { + if t, err := entry.Type.Get(); err == nil && t == api.ApiKeyResponseTypePublishable && entry.Name == "default" { + return "PUBLISHABLE" + } + return strings.ToUpper(entry.Name) +} + func toValue(v nullable.Nullable[string]) string { if value, err := v.Get(); err == nil { return value diff --git a/apps/cli-go/internal/projects/apiKeys/api_keys_test.go b/apps/cli-go/internal/projects/apiKeys/api_keys_test.go index 82030c003c..555200204f 100644 --- a/apps/cli-go/internal/projects/apiKeys/api_keys_test.go +++ b/apps/cli-go/internal/projects/apiKeys/api_keys_test.go @@ -73,3 +73,58 @@ func TestProjectApiKeysCommand(t *testing.T) { assert.ErrorContains(t, err, "unexpected get api keys status 503:") }) } + +func TestToEnv(t *testing.T) { + t.Run("maps legacy keys by name only", func(t *testing.T) { + envs := ToEnv([]api.ApiKeyResponse{{ + Name: "anon", + ApiKey: nullable.NewNullableWithValue("anon-key"), + }, { + Name: "service_role", + ApiKey: nullable.NewNullNullable[string](), + }}) + assert.Equal(t, map[string]string{ + "SUPABASE_ANON_KEY": "anon-key", + "SUPABASE_SERVICE_ROLE_KEY": "******", + }, envs) + }) + + t.Run("adds SUPABASE_PUBLISHABLE_KEY for new-format keys", func(t *testing.T) { + envs := ToEnv([]api.ApiKeyResponse{{ + Name: "default", + Type: nullable.NewNullableWithValue(api.ApiKeyResponseTypePublishable), + ApiKey: nullable.NewNullableWithValue("sb_publishable_test"), + }, { + Name: "default", + Type: nullable.NewNullableWithValue(api.ApiKeyResponseTypeSecret), + ApiKey: nullable.NewNullableWithValue("sb_secret_test"), + }}) + assert.Equal(t, "sb_publishable_test", envs["SUPABASE_PUBLISHABLE_KEY"]) + assert.Equal(t, "sb_secret_test", envs["SUPABASE_DEFAULT_KEY"]) + }) + + t.Run("maps default publishable to SUPABASE_PUBLISHABLE_KEY alongside custom names", func(t *testing.T) { + envs := ToEnv([]api.ApiKeyResponse{{ + Name: "mobile", + Type: nullable.NewNullableWithValue(api.ApiKeyResponseTypePublishable), + ApiKey: nullable.NewNullableWithValue("sb_publishable_mobile"), + }, { + Name: "default", + Type: nullable.NewNullableWithValue(api.ApiKeyResponseTypePublishable), + ApiKey: nullable.NewNullableWithValue("sb_publishable_default"), + }}) + assert.Equal(t, map[string]string{ + "SUPABASE_MOBILE_KEY": "sb_publishable_mobile", + "SUPABASE_PUBLISHABLE_KEY": "sb_publishable_default", + }, envs) + }) + + t.Run("masks null publishable api key", func(t *testing.T) { + envs := ToEnv([]api.ApiKeyResponse{{ + Name: "default", + Type: nullable.NewNullableWithValue(api.ApiKeyResponseTypePublishable), + ApiKey: nullable.NewNullNullable[string](), + }}) + assert.Equal(t, "******", envs["SUPABASE_PUBLISHABLE_KEY"]) + }) +} diff --git a/apps/cli/src/legacy/commands/branches/branches.format.unit.test.ts b/apps/cli/src/legacy/commands/branches/branches.format.unit.test.ts index 680d60b489..a2b6ca496e 100644 --- a/apps/cli/src/legacy/commands/branches/branches.format.unit.test.ts +++ b/apps/cli/src/legacy/commands/branches/branches.format.unit.test.ts @@ -128,6 +128,46 @@ describe("apiKeysToEnv", () => { }); }); + it("adds SUPABASE_PUBLISHABLE_KEY for publishable keys", () => { + expect( + apiKeysToEnv([ + { + name: "default", + type: "publishable", + api_key: "sb_publishable_test", + }, + { + name: "default", + type: "secret", + api_key: "sb_secret_test", + }, + ]), + ).toEqual({ + SUPABASE_DEFAULT_KEY: "sb_secret_test", + SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test", + }); + }); + + it("maps default publishable to SUPABASE_PUBLISHABLE_KEY alongside custom names", () => { + expect( + apiKeysToEnv([ + { + name: "mobile", + type: "publishable", + api_key: "sb_publishable_mobile", + }, + { + name: "default", + type: "publishable", + api_key: "sb_publishable_default", + }, + ]), + ).toEqual({ + SUPABASE_MOBILE_KEY: "sb_publishable_mobile", + SUPABASE_PUBLISHABLE_KEY: "sb_publishable_default", + }); + }); + it("masks null/undefined api_key as ******", () => { expect(apiKeysToEnv([{ name: "anon", api_key: null }])).toEqual({ SUPABASE_ANON_KEY: "******", diff --git a/apps/cli/src/legacy/commands/branches/get/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/branches/get/SIDE_EFFECTS.md index babda6ee3b..a51ec86098 100644 --- a/apps/cli/src/legacy/commands/branches/get/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/branches/get/SIDE_EFFECTS.md @@ -49,4 +49,4 @@ Glamour-styled 7-column table: `HOST`, `PORT`, `USER`, `PASSWORD`, `JWT SECRET`, ### `--output {json,yaml,toml,env}` / `--output-format json` / `stream-json` -Emits the standard-env projection: `POSTGRES_URL` (pooled, falls back to direct on parse failure with `WARNING:` to stderr), `POSTGRES_URL_NON_POOLING` (direct), `SUPABASE_URL = https://.`, `SUPABASE_JWT_SECRET`, plus `SUPABASE__KEY` per API key. +Emits the standard-env projection: `POSTGRES_URL` (pooled, falls back to direct on parse failure with `WARNING:` to stderr), `POSTGRES_URL_NON_POOLING` (direct), `SUPABASE_URL = https://.`, `SUPABASE_JWT_SECRET`, plus `SUPABASE__KEY` per API key. Publishable keys named `default` map to `SUPABASE_PUBLISHABLE_KEY` (not `SUPABASE_DEFAULT_KEY`) to avoid colliding with the default secret key. diff --git a/apps/cli/src/legacy/commands/branches/get/get.integration.test.ts b/apps/cli/src/legacy/commands/branches/get/get.integration.test.ts index f02e294f14..27ae81902d 100644 --- a/apps/cli/src/legacy/commands/branches/get/get.integration.test.ts +++ b/apps/cli/src/legacy/commands/branches/get/get.integration.test.ts @@ -103,6 +103,7 @@ interface SetupOpts { readonly poolerStatus?: number; readonly poolerBody?: Pooler; readonly apiKeysStatus?: number; + readonly apiKeysBody?: ApiKeys; readonly skipPrimary?: boolean; } @@ -113,6 +114,7 @@ function buildApi(opts: SetupOpts) { const poolerStatus = opts.poolerStatus ?? 200; const poolerBody = opts.poolerBody ?? POOLER; const apiKeysStatus = opts.apiKeysStatus ?? 200; + const apiKeysBody = opts.apiKeysBody ?? KEYS; return mockLegacyPlatformApi({ handler: (request) => Effect.sync(() => { @@ -130,7 +132,11 @@ function buildApi(opts: SetupOpts) { ); } if (request.method === "GET" && request.url.includes("/api-keys")) { - return legacyJsonResponse(request, apiKeysStatus, apiKeysStatus === 200 ? KEYS : []); + return legacyJsonResponse( + request, + apiKeysStatus, + apiKeysStatus === 200 ? apiKeysBody : [], + ); } if (request.method === "GET" && request.url.includes("/config/database/pooler")) { const body = opts.skipPrimary @@ -214,6 +220,30 @@ describe("legacy branches get integration", () => { }).pipe(Effect.provide(layer)); }); + it.live("emits SUPABASE_PUBLISHABLE_KEY for new-format api keys", () => { + const newFormatKeys: ApiKeys = [ + { + name: "default", + type: "publishable", + api_key: "sb_publishable_test", + }, + { + name: "default", + type: "secret", + api_key: "sb_secret_test", + }, + ]; + const { layer, out } = setup({ format: "json", apiKeysBody: newFormatKeys }); + return Effect.gen(function* () { + yield* legacyBranchesGet({ ...baseFlags, name: Option.some(BRANCH_UUID) }); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toMatchObject({ + SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test", + SUPABASE_DEFAULT_KEY: "sb_secret_test", + }); + }).pipe(Effect.provide(layer)); + }); + it.live("emits standard-env map for --output env (env-format encoder)", () => { const { layer, out } = setup({ goOutput: "env" }); return Effect.gen(function* () { diff --git a/apps/cli/src/legacy/commands/projects/projects.format.unit.test.ts b/apps/cli/src/legacy/commands/projects/projects.format.unit.test.ts index 6e9385f4aa..95e1bfd6f7 100644 --- a/apps/cli/src/legacy/commands/projects/projects.format.unit.test.ts +++ b/apps/cli/src/legacy/commands/projects/projects.format.unit.test.ts @@ -84,6 +84,26 @@ describe("apiKeyValue / apiKeysToEnv", () => { SUPABASE_SERVICE_ROLE_KEY: "******", }); }); + + it("adds SUPABASE_PUBLISHABLE_KEY for publishable keys", () => { + expect( + apiKeysToEnv([ + { + name: "default", + type: "publishable", + api_key: "sb_publishable_test", + }, + { + name: "default", + type: "secret", + api_key: "sb_secret_test", + }, + ]), + ).toEqual({ + SUPABASE_DEFAULT_KEY: "sb_secret_test", + SUPABASE_PUBLISHABLE_KEY: "sb_publishable_test", + }); + }); }); describe("generateDbPassword", () => { diff --git a/apps/cli/src/legacy/shared/legacy-api-keys.format.ts b/apps/cli/src/legacy/shared/legacy-api-keys.format.ts index 1c36d1de47..9d1efbf91d 100644 --- a/apps/cli/src/legacy/shared/legacy-api-keys.format.ts +++ b/apps/cli/src/legacy/shared/legacy-api-keys.format.ts @@ -16,16 +16,23 @@ export function apiKeyValue(value: string | null | undefined): string { return value === undefined || value === null ? API_KEY_MASK : value; } +function envSuffix(entry: ApiKey): string { + if (entry.type === "publishable" && entry.name === "default") { + return "PUBLISHABLE"; + } + return entry.name.toUpperCase(); +} + /** - * Reproduces Go's `apiKeys.ToEnv` (`api_keys.go:51-66`): - * uppercase the name, wrap as `SUPABASE__KEY`, fall back to `"******"` - * when the api_key value is nullable-null. Shared by `branches get` and - * `projects api-keys`. + * Reproduces Go's `apiKeys.ToEnv` (`api_keys.go:51-68`): + * uppercase the name (with `default` publishable → `PUBLISHABLE`), wrap as + * `SUPABASE__KEY`, fall back to `"******"` when the api_key value is + * nullable-null. Shared by `branches get` and `projects api-keys`. */ export function apiKeysToEnv(keys: ReadonlyArray): Record { const envs: Record = {}; for (const entry of keys) { - const key = `SUPABASE_${entry.name.toUpperCase()}_KEY`; + const key = `SUPABASE_${envSuffix(entry)}_KEY`; envs[key] = apiKeyValue(entry.api_key); } return envs;