Skip to content
Merged
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
7 changes: 7 additions & 0 deletions apps/cli/docs/go-cli-porting-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,3 +316,10 @@ Legend:
| `db remote commit` | `wrapped` | [`../src/legacy/commands/db/remote/commit/commit.command.ts`](../src/legacy/commands/db/remote/commit/commit.command.ts) |
| `db schema declarative sync` | `ported` | [`../src/legacy/commands/db/schema/declarative/sync/sync.command.ts`](../src/legacy/commands/db/schema/declarative/sync/sync.command.ts) |
| `db schema declarative generate` | `ported` | [`../src/legacy/commands/db/schema/declarative/generate/generate.command.ts`](../src/legacy/commands/db/schema/declarative/generate/generate.command.ts) |

Flag divergences from the Go reference:

- `projects api-keys` has a TS-only `--reveal` flag (no Go equivalent). It sends
`reveal=true` so the Management API returns the full secret keys (`sb_secret_...`) in
full instead of redacting them, addressing issue #4775. Default behavior (omitted flag)
matches Go exactly.
27 changes: 17 additions & 10 deletions apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,13 @@

## API Routes

| Method | Path | Auth | Request body | Response (used fields) |
| ------ | ----------------------------- | ------------ | ------------ | ------------------------------------------- |
| `GET` | `/v1/projects/{ref}/api-keys` | Bearer token | none | `[{name: string, api_key: string \| null}]` |
| Method | Path | Auth | Request body | Response (used fields) |
| ------ | ------------------------------------------- | ------------ | ------------ | ------------------------------------------- |
| `GET` | `/v1/projects/{ref}/api-keys[?reveal=true]` | Bearer token | none | `[{name: string, api_key: string \| null}]` |

The `reveal=true` query param is sent only when `--reveal` is passed; it instructs the
Management API to return the full secret keys (`sb_secret_...`) in `api_key` instead of
`null`. Without `--reveal` the param is omitted entirely (default request).

## Environment Variables

Expand All @@ -28,9 +32,10 @@

## Flags

| Flag | Type | Required | Description |
| --------------- | ------ | -------- | --------------------------------------------------------------------------- |
| `--project-ref` | string | no | Project ref of the Supabase project (resolved from linked config if absent) |
| Flag | Type | Required | Description |
| --------------- | ------- | -------- | --------------------------------------------------------------------------- |
| `--project-ref` | string | no | Project ref of the Supabase project (resolved from linked config if absent) |
| `--reveal` | boolean | no | Reveal the secret API keys in full (sends `reveal=true`); default redacted |

## Exit Codes

Expand All @@ -44,9 +49,9 @@

## Telemetry Events Fired

| Event | When | Notable properties / groups |
| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------- |
| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` is telemetry-safe) |
| Event | When | Notable properties / groups |
| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------- |
| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` is telemetry-safe; `--reveal`'s boolean value is logged but never the key values) |

## Output

Expand Down Expand Up @@ -95,7 +100,9 @@ On failure, an `error` event is emitted instead:
## Notes

- API keys with null values (redacted by the API) render as `******` in text mode and
in the toml/env env map; the json/yaml encodings preserve the raw `null`.
in the toml/env env map; the json/yaml encodings preserve the raw `null`. Passing
`--reveal` makes the API return the secret values, so they print in full across all
formats (issue #4775). This is a TS-only flag with no Go CLI equivalent.
- The `--project-ref` flag is optional when the CLI is linked to a project via `supabase link`.
When omitted, the ref is resolved flag → env → `.temp/project-ref` → prompt on a TTY,
failing with a not-linked error otherwise.
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ const config = {
Flag.withDescription("Project ref of the Supabase project."),
Flag.optional,
),
reveal: Flag.boolean("reveal").pipe(
Flag.withDescription("Reveal the secret API keys in full (e.g. sb_secret_...)."),
),
};
export type LegacyProjectsApiKeysFlags = CliCommand.Command.Config.Infer<typeof config>;

Expand All @@ -21,9 +24,16 @@ export const legacyProjectsApiKeysCommand = Command.make("api-keys", config).pip
command: "supabase projects api-keys --project-ref abcdefghijklmnopqrst",
description: "List all API keys for a project",
},
{
command: "supabase projects api-keys --reveal --output json",
description: "List API keys with the secret keys revealed in full",
},
]),
Command.withHandler((flags) =>
legacyProjectsApiKeys(flags).pipe(
// `reveal` is intentionally not in `safeFlags`: it is a boolean flag, and
// boolean values are always logged verbatim by the instrumentation. Only
// string flags Go marks with `markFlagTelemetrySafe` belong in `safeFlags`.
withLegacyCommandInstrumentation({ flags, safeFlags: ["project-ref"] }),
withJsonErrorHandling,
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export const legacyProjectsApiKeys = Effect.fn("legacy.projects.api-keys")(funct
yield* Effect.gen(function* () {
const fetching =
output.format === "text" ? yield* output.task("Fetching API keys...") : undefined;
const keys: ApiKeys = yield* legacyGetProjectApiKeys(ref).pipe(
const keys: ApiKeys = yield* legacyGetProjectApiKeys(ref, flags.reveal).pipe(
Effect.tapError(() => fetching?.fail() ?? Effect.void),
);
yield* fetching?.clear() ?? Effect.void;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@ const SAMPLE_KEYS: ApiKeys = [
{ name: "service_role", api_key: null },
];

const REVEALED_KEYS: ApiKeys = [
{ name: "anon", api_key: "anon-secret" },
{ name: "service_role", api_key: "sb_secret_revealed" },
];

const FLAG_REF = "qrstuvwxyzabcdefghij";

const tempRoot = useLegacyTempWorkdir("supabase-projects-apikeys-int-");
Expand Down Expand Up @@ -55,7 +60,7 @@ describe("legacy projects api-keys integration", () => {
it.live("lists api keys as a NAME / KEY VALUE table and masks null values", () => {
const { layer, out } = setup();
return Effect.gen(function* () {
yield* legacyProjectsApiKeys({ projectRef: Option.none() });
yield* legacyProjectsApiKeys({ projectRef: Option.none(), reveal: false });
expect(out.stdoutText).toContain("NAME");
expect(out.stdoutText).toContain("KEY VALUE");
expect(out.stdoutText).toContain("anon-secret");
Expand All @@ -66,23 +71,75 @@ describe("legacy projects api-keys integration", () => {
it.live("resolves the ref from --project-ref", () => {
const { layer, api } = setup();
return Effect.gen(function* () {
yield* legacyProjectsApiKeys({ projectRef: Option.some(FLAG_REF) });
yield* legacyProjectsApiKeys({ projectRef: Option.some(FLAG_REF), reveal: false });
expect(api.requests[0]?.url).toContain(`/v1/projects/${FLAG_REF}/api-keys`);
}).pipe(Effect.provide(layer));
});

it.live("resolves the ref from the linked project when --project-ref is omitted", () => {
const { layer, api } = setup();
return Effect.gen(function* () {
yield* legacyProjectsApiKeys({ projectRef: Option.none() });
yield* legacyProjectsApiKeys({ projectRef: Option.none(), reveal: false });
expect(api.requests[0]?.url).toContain(`/v1/projects/${LEGACY_VALID_REF}/api-keys`);
}).pipe(Effect.provide(layer));
});

it.live("omits the reveal query param by default (Go request parity)", () => {
const { layer, api } = setup();
return Effect.gen(function* () {
yield* legacyProjectsApiKeys({ projectRef: Option.none(), reveal: false });
expect(api.requests[0]?.urlWithParams).not.toContain("reveal");
}).pipe(Effect.provide(layer));
});

it.live("sends reveal=true when --reveal is passed", () => {
const { layer, api } = setup({ response: REVEALED_KEYS });
return Effect.gen(function* () {
yield* legacyProjectsApiKeys({ projectRef: Option.none(), reveal: true });
expect(api.requests[0]?.urlWithParams).toContain("reveal=true");
}).pipe(Effect.provide(layer));
});

it.live("renders the revealed secret key in full in the text table", () => {
const { layer, out } = setup({ response: REVEALED_KEYS });
return Effect.gen(function* () {
yield* legacyProjectsApiKeys({ projectRef: Option.none(), reveal: true });
expect(out.stdoutText).toContain("sb_secret_revealed");
expect(out.stdoutText).not.toContain("******");
}).pipe(Effect.provide(layer));
});

it.live("includes the revealed secret in the env map for --output env --reveal", () => {
const { layer, out } = setup({ goOutput: "env", response: REVEALED_KEYS });
return Effect.gen(function* () {
yield* legacyProjectsApiKeys({ projectRef: Option.none(), reveal: true });
expect(out.stdoutText).toContain('SUPABASE_SERVICE_ROLE_KEY="sb_secret_revealed"');
}).pipe(Effect.provide(layer));
});

it.live("carries the revealed secret in the { keys } payload for --output-format json", () => {
const { layer, out } = setup({ format: "json", response: REVEALED_KEYS });
return Effect.gen(function* () {
yield* legacyProjectsApiKeys({ projectRef: Option.none(), reveal: true });
const success = out.messages.find((m) => m.type === "success");
expect(success?.data).toMatchObject({ keys: REVEALED_KEYS });
}).pipe(Effect.provide(layer));
});

it.live("emits the revealed secret in the Go json array for --output json --reveal", () => {
const { layer, out } = setup({ goOutput: "json", response: REVEALED_KEYS });
return Effect.gen(function* () {
yield* legacyProjectsApiKeys({ projectRef: Option.none(), reveal: true });
expect(out.stdoutText).toContain('"api_key": "sb_secret_revealed"');
}).pipe(Effect.provide(layer));
});

it.live("fails with LegacyProjectNotLinkedError when no ref can be resolved", () => {
const { layer } = setup({ projectId: Option.none() });
return Effect.gen(function* () {
const exit = yield* Effect.exit(legacyProjectsApiKeys({ projectRef: Option.none() }));
const exit = yield* Effect.exit(
legacyProjectsApiKeys({ projectRef: Option.none(), reveal: false }),
);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
expect(JSON.stringify(exit.cause)).toContain("LegacyProjectNotLinkedError");
Expand All @@ -93,7 +150,7 @@ describe("legacy projects api-keys integration", () => {
it.live("emits a success event with { keys } for --output-format json", () => {
const { layer, out } = setup({ format: "json" });
return Effect.gen(function* () {
yield* legacyProjectsApiKeys({ projectRef: Option.none() });
yield* legacyProjectsApiKeys({ projectRef: Option.none(), reveal: false });
const success = out.messages.find((m) => m.type === "success");
expect(success?.data).toMatchObject({ keys: SAMPLE_KEYS });
}).pipe(Effect.provide(layer));
Expand All @@ -102,15 +159,15 @@ describe("legacy projects api-keys integration", () => {
it.live("emits a success event for --output-format stream-json", () => {
const { layer, out } = setup({ format: "stream-json" });
return Effect.gen(function* () {
yield* legacyProjectsApiKeys({ projectRef: Option.none() });
yield* legacyProjectsApiKeys({ projectRef: Option.none(), reveal: false });
expect(out.messages.find((m) => m.type === "success")).toBeDefined();
}).pipe(Effect.provide(layer));
});

it.live("encodes the SUPABASE_<NAME>_KEY map for --output env", () => {
const { layer, out } = setup({ goOutput: "env" });
return Effect.gen(function* () {
yield* legacyProjectsApiKeys({ projectRef: Option.none() });
yield* legacyProjectsApiKeys({ projectRef: Option.none(), reveal: false });
expect(out.stdoutText).toContain('SUPABASE_ANON_KEY="anon-secret"');
expect(out.stdoutText).toContain('SUPABASE_SERVICE_ROLE_KEY="******"');
}).pipe(Effect.provide(layer));
Expand All @@ -119,15 +176,15 @@ describe("legacy projects api-keys integration", () => {
it.live("encodes the SUPABASE_<NAME>_KEY map for --output toml", () => {
const { layer, out } = setup({ goOutput: "toml" });
return Effect.gen(function* () {
yield* legacyProjectsApiKeys({ projectRef: Option.none() });
yield* legacyProjectsApiKeys({ projectRef: Option.none(), reveal: false });
expect(out.stdoutText).toContain('SUPABASE_ANON_KEY = "anon-secret"');
}).pipe(Effect.provide(layer));
});

it.live("emits a JSON array of api keys for --output json", () => {
const { layer, out } = setup({ goOutput: "json" });
return Effect.gen(function* () {
yield* legacyProjectsApiKeys({ projectRef: Option.none() });
yield* legacyProjectsApiKeys({ projectRef: Option.none(), reveal: false });
expect(out.stdoutText).toContain('"name": "anon"');
expect(out.stdoutText.startsWith("[\n")).toBe(true);
}).pipe(Effect.provide(layer));
Expand All @@ -136,15 +193,17 @@ describe("legacy projects api-keys integration", () => {
it.live("emits a YAML array for --output yaml", () => {
const { layer, out } = setup({ goOutput: "yaml" });
return Effect.gen(function* () {
yield* legacyProjectsApiKeys({ projectRef: Option.none() });
yield* legacyProjectsApiKeys({ projectRef: Option.none(), reveal: false });
expect(out.stdoutText).toContain("name: anon");
}).pipe(Effect.provide(layer));
});

it.live("fails with LegacyProjectsApiKeysNetworkError on transport failure", () => {
const { layer } = setup({ network: "fail" });
return Effect.gen(function* () {
const exit = yield* Effect.exit(legacyProjectsApiKeys({ projectRef: Option.none() }));
const exit = yield* Effect.exit(
legacyProjectsApiKeys({ projectRef: Option.none(), reveal: false }),
);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const json = JSON.stringify(exit.cause);
Expand All @@ -157,7 +216,9 @@ describe("legacy projects api-keys integration", () => {
it.live("maps HTTP 503 to `unexpected get api keys status 503`", () => {
const { layer } = setup({ status: 503, response: [] });
return Effect.gen(function* () {
const exit = yield* Effect.exit(legacyProjectsApiKeys({ projectRef: Option.none() }));
const exit = yield* Effect.exit(
legacyProjectsApiKeys({ projectRef: Option.none(), reveal: false }),
);
expect(Exit.isFailure(exit)).toBe(true);
if (Exit.isFailure(exit)) {
const json = JSON.stringify(exit.cause);
Expand Down
17 changes: 11 additions & 6 deletions apps/cli/src/legacy/shared/legacy-get-api-keys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,20 @@ const mapApiKeysError = mapLegacyHttpError({

/**
* Ports Go's `apiKeys.RunGetApiKeys` (`apps/cli-go/internal/projects/apiKeys/api_keys.go:41-49`):
* `GET /v1/projects/{ref}/api-keys` with no `reveal` param, mapping transport /
* non-200 failures to the same `failed to get api keys` / `unexpected get api keys
* status` errors Go raises. Shared by `projects api-keys` (display) and `bootstrap`
* (which derives the `.env` keys).
* `GET /v1/projects/{ref}/api-keys`, mapping transport / non-200 failures to the same
* `failed to get api keys` / `unexpected get api keys status` errors Go raises. Shared by
* `projects api-keys` (display) and `bootstrap` (which derives the `.env` keys).
*
* When `reveal` is `true`, the `reveal=true` query param is sent so the Management API
* returns the full secret keys (prefix `sb_secret_`) in `api_key` instead of `null`
* (issue #4775). The param is omitted entirely when `reveal` is `false` to keep the
* default request byte-identical to Go's (`bootstrap` only consumes the never-redacted
* anon key, so it stays on the default path).
*/
export const legacyGetProjectApiKeys = Effect.fnUntraced(function* (ref: string) {
export const legacyGetProjectApiKeys = Effect.fnUntraced(function* (ref: string, reveal = false) {
const api = yield* LegacyPlatformApi;
const keys: ApiKeys = yield* api.v1
.getProjectApiKeys({ ref })
.getProjectApiKeys(reveal ? { ref, reveal: true } : { ref })
.pipe(Effect.catch(mapApiKeysError));
return keys;
});
Loading