diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 34e67cc4b2..fb8beae0a9 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -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. diff --git a/apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md index 2071a7aefc..79a7642c2c 100644 --- a/apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md +++ b/apps/cli/src/legacy/commands/projects/api-keys/SIDE_EFFECTS.md @@ -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 @@ -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 @@ -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 @@ -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. diff --git a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.command.ts b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.command.ts index eba15b639b..daf45197f2 100644 --- a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.command.ts +++ b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.command.ts @@ -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; @@ -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, ), diff --git a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.handler.ts b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.handler.ts index 2453c67583..fadbf65a63 100644 --- a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.handler.ts +++ b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.handler.ts @@ -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; diff --git a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.integration.test.ts b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.integration.test.ts index 2865fcde89..edf3d9cbde 100644 --- a/apps/cli/src/legacy/commands/projects/api-keys/api-keys.integration.test.ts +++ b/apps/cli/src/legacy/commands/projects/api-keys/api-keys.integration.test.ts @@ -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-"); @@ -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"); @@ -66,7 +71,7 @@ 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)); }); @@ -74,15 +79,67 @@ describe("legacy projects api-keys integration", () => { 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"); @@ -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)); @@ -102,7 +159,7 @@ 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)); }); @@ -110,7 +167,7 @@ describe("legacy projects api-keys integration", () => { it.live("encodes the SUPABASE__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)); @@ -119,7 +176,7 @@ describe("legacy projects api-keys integration", () => { it.live("encodes the SUPABASE__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)); }); @@ -127,7 +184,7 @@ describe("legacy projects api-keys integration", () => { 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)); @@ -136,7 +193,7 @@ 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)); }); @@ -144,7 +201,9 @@ describe("legacy projects api-keys integration", () => { 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); @@ -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); diff --git a/apps/cli/src/legacy/shared/legacy-get-api-keys.ts b/apps/cli/src/legacy/shared/legacy-get-api-keys.ts index cd60147229..80b498bf44 100644 --- a/apps/cli/src/legacy/shared/legacy-get-api-keys.ts +++ b/apps/cli/src/legacy/shared/legacy-get-api-keys.ts @@ -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; });