Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions apps/cli-go/internal/branches/get/get_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:[email protected]:6543/postgres?connect_timeout=10"
POSTGRES_URL_NON_POOLING = "postgresql://postgres:[email protected]: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:[email protected]: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))
Expand Down
13 changes: 11 additions & 2 deletions apps/cli-go/internal/projects/apiKeys/api_keys.go
Original file line number Diff line number Diff line change
Expand Up @@ -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_<SUFFIX>_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
Expand Down
55 changes: 55 additions & 0 deletions apps/cli-go/internal/projects/apiKeys/api_keys_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -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: "******",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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://<ref>.<project_host>`, `SUPABASE_JWT_SECRET`, plus `SUPABASE_<NAME>_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://<ref>.<project_host>`, `SUPABASE_JWT_SECRET`, plus `SUPABASE_<SUFFIX>_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.
Original file line number Diff line number Diff line change
Expand Up @@ -103,6 +103,7 @@ interface SetupOpts {
readonly poolerStatus?: number;
readonly poolerBody?: Pooler;
readonly apiKeysStatus?: number;
readonly apiKeysBody?: ApiKeys;
readonly skipPrimary?: boolean;
}

Expand All @@ -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(() => {
Expand All @@ -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
Expand Down Expand Up @@ -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* () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
17 changes: 12 additions & 5 deletions apps/cli/src/legacy/shared/legacy-api-keys.format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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_<NAME>_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_<SUFFIX>_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<ApiKey>): Record<string, string> {
const envs: Record<string, string> = {};
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;
Expand Down