diff --git a/apps/cli/docs/go-cli-porting-status.md b/apps/cli/docs/go-cli-porting-status.md index 4c26e200a9..f17de55366 100644 --- a/apps/cli/docs/go-cli-porting-status.md +++ b/apps/cli/docs/go-cli-porting-status.md @@ -254,8 +254,8 @@ Legend: | `network-bans remove` | `ported` | [`../src/legacy/commands/network-bans/remove/remove.command.ts`](../src/legacy/commands/network-bans/remove/remove.command.ts) | | `network-restrictions get` | `ported` | [`../src/legacy/commands/network-restrictions/get/get.command.ts`](../src/legacy/commands/network-restrictions/get/get.command.ts) | | `network-restrictions update` | `ported` | [`../src/legacy/commands/network-restrictions/update/update.command.ts`](../src/legacy/commands/network-restrictions/update/update.command.ts) | -| `encryption get-root-key` | `wrapped` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | -| `encryption update-root-key` | `wrapped` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | +| `encryption get-root-key` | `ported` | [`../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts`](../src/legacy/commands/encryption/get-root-key/get-root-key.command.ts) | +| `encryption update-root-key` | `ported` | [`../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts`](../src/legacy/commands/encryption/update-root-key/update-root-key.command.ts) | | `ssl-enforcement get` | `ported` | [`../src/legacy/commands/ssl-enforcement/get/get.command.ts`](../src/legacy/commands/ssl-enforcement/get/get.command.ts) | | `ssl-enforcement update` | `ported` | [`../src/legacy/commands/ssl-enforcement/update/update.command.ts`](../src/legacy/commands/ssl-enforcement/update/update.command.ts) | | `postgres-config get` | `ported` | [`../src/legacy/commands/postgres-config/get/get.command.ts`](../src/legacy/commands/postgres-config/get/get.command.ts) | diff --git a/apps/cli/src/legacy/commands/encryption/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/encryption/SIDE_EFFECTS.md new file mode 100644 index 0000000000..458b7aeb86 --- /dev/null +++ b/apps/cli/src/legacy/commands/encryption/SIDE_EFFECTS.md @@ -0,0 +1,88 @@ +# `supabase encryption [get-root-key|update-root-key]` + +Manage a project's pgsodium root encryption key. Each subcommand resolves a +project ref and calls one Management API endpoint. `update-root-key` +additionally reads the new key from stdin. + +## Files Read + +| Path | Format | When | +| ------------------------------------------------ | ------------------------- | ------------------------------------------------------------------ | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `~/.supabase//linked-project.json` | JSON | when `--project-ref` / `PROJECT_ID` unset, to resolve linked ref | +| stdin | raw bytes / masked TTY | `update-root-key` only — masked TTY input or piped bytes (the key) | + +## Files Written + +| Path | Format | When | +| ------------------------------------------------ | ------ | ------------------------------------------------- | +| `~/.supabase//linked-project.json` | JSON | PersistentPostRun, after the project ref resolves | +| `~/.supabase/telemetry.json` | JSON | PersistentPostRun, on success or failure | + +## API Routes + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------- | ------------ | ------------ | ---------------------- | +| `GET` | `/v1/projects/{ref}/pgsodium` | Bearer token | none | `{root_key}` | +| `PUT` | `/v1/projects/{ref}/pgsodium` | Bearer token | `{root_key}` | `{root_key}` | + +`get-root-key` calls `GET`; `update-root-key` calls `PUT`. + +## Environment Variables + +| Variable | Purpose | Required? | +| ------------------------------------ | ---------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` / `PROJECT_ID` | project ref (fallback when `--project-ref` unset) | no (falls back to linked-project file → prompt) | +| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (defaults to `supabase`) | + +## Exit Codes + +| Code | Condition | +| ---- | ----------------------------------------- | +| `0` | success | +| `1` | project ref unresolved / malformed | +| `1` | network / connection failure | +| `1` | non-200 status from the pgsodium endpoint | + +## 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` redacted — not telemetry-safe) | + +No custom `phtelemetry.*` events in `internal/encryption/`. + +## Output + +### `--output-format text` (Go CLI compatible) + +- `get-root-key`: the bare root key followed by a newline, to **stdout** (Go `fmt.Println`). +- `update-root-key`: `Finished supabase root-key update.` followed by a newline, to **stderr** + (Go's `utils.Aqua` color rendered as plain text per the legacy-port convention). + +### `--output-format json` + +A single JSON object emitted to stdout: `{"root_key":"…"}` (both subcommands). + +### `--output-format stream-json` + +One `result` event carrying `{root_key}` (both subcommands). + +```ndjson +{"type":"result","data":{"root_key":"…"}} +``` + +## Notes + +- Requires `--project-ref`, `SUPABASE_PROJECT_ID`/`PROJECT_ID`, or a linked project. +- `update-root-key` reads the key from stdin: a real TTY is read with a masked + prompt; piped stdin is decoded as UTF-8 and whitespace-trimmed. An empty or + whitespace-only key sends an empty `root_key`, matching Go's `io.Copy` + + `strings.TrimSpace` behavior. (The TTY masked prompt also trims, matching Go.) +- **Known divergence:** Go writes the bare prompt `Enter a new root key: ` to + stderr and reads via `term.ReadPassword`. The port uses a clack masked prompt + with the same label text, so the rendered TTY prompt is not byte-identical to + Go (clack adds its own framing). Piped (non-TTY) mode does not print the prompt + at all — it reads stdin directly, as Go's `io.Copy` branch does. diff --git a/apps/cli/src/legacy/commands/encryption/encryption.command.ts b/apps/cli/src/legacy/commands/encryption/encryption.command.ts index 7e4e743704..3a0bd1a8b5 100644 --- a/apps/cli/src/legacy/commands/encryption/encryption.command.ts +++ b/apps/cli/src/legacy/commands/encryption/encryption.command.ts @@ -3,7 +3,7 @@ import { legacyEncryptionGetRootKeyCommand } from "./get-root-key/get-root-key.c import { legacyEncryptionUpdateRootKeyCommand } from "./update-root-key/update-root-key.command.ts"; export const legacyEncryptionCommand = Command.make("encryption").pipe( - Command.withDescription("Manage encryption keys of Supabase projects."), + Command.withDescription("Manage encryption keys of Supabase projects"), Command.withShortDescription("Manage encryption keys"), Command.withSubcommands([ legacyEncryptionGetRootKeyCommand, diff --git a/apps/cli/src/legacy/commands/encryption/encryption.e2e.test.ts b/apps/cli/src/legacy/commands/encryption/encryption.e2e.test.ts new file mode 100644 index 0000000000..528f35dca9 --- /dev/null +++ b/apps/cli/src/legacy/commands/encryption/encryption.e2e.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "vitest"; +import { runSupabase } from "../../../../tests/helpers/cli.ts"; + +const E2E_TIMEOUT_MS = 30_000; +const TEST_TOKEN = "sbp_" + "a".repeat(40); + +describe("supabase encryption (legacy)", () => { + // Golden-path e2e: validates real subprocess dispatch + ref-resolution error + // wiring for the get path. With an isolated HOME and no --project-ref / + // SUPABASE_PROJECT_ID, the resolver fails before any API call. + test( + "get-root-key without a resolvable project ref exits non-zero with the not-linked message", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout, stderr } = await runSupabase(["encryption", "get-root-key"], { + entrypoint: "legacy", + env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN }, + }); + expect(exitCode).not.toBe(0); + expect(`${stdout}${stderr}`).toContain("Cannot find project ref"); + }, + ); + + // Validates the piped-stdin read path reaches the resolver in a real + // subprocess — the key is consumed from stdin, then ref resolution fails. + test( + "update-root-key with piped key but no resolvable ref exits non-zero", + { timeout: E2E_TIMEOUT_MS }, + async () => { + const { exitCode, stdout, stderr } = await runSupabase(["encryption", "update-root-key"], { + entrypoint: "legacy", + env: { SUPABASE_ACCESS_TOKEN: TEST_TOKEN }, + stdin: "newkey\n", + }); + expect(exitCode).not.toBe(0); + expect(`${stdout}${stderr}`).toContain("Cannot find project ref"); + }, + ); +}); diff --git a/apps/cli/src/legacy/commands/encryption/encryption.errors.ts b/apps/cli/src/legacy/commands/encryption/encryption.errors.ts new file mode 100644 index 0000000000..cf57170f97 --- /dev/null +++ b/apps/cli/src/legacy/commands/encryption/encryption.errors.ts @@ -0,0 +1,44 @@ +import { Data } from "effect"; + +import { mapLegacyHttpError } from "../../shared/legacy-http-errors.ts"; + +/** + * Transport-level failure talking to the Management API pgsodium endpoints. + * Mirrors Go's `errors.Errorf("failed to pgsodium config: %w", err)` + * (`apps/cli-go/internal/encryption/{get,update}`). + */ +class LegacyEncryptionNetworkError extends Data.TaggedError("LegacyEncryptionNetworkError")<{ + readonly message: string; +}> {} + +/** + * The pgsodium endpoint returned a status the Go CLI does not treat as success + * (it only accepts `JSON200`). Mirrors Go's + * `errors.Errorf("unexpected pgsodium config status %d: %s", code, body)`. + */ +class LegacyEncryptionUnexpectedStatusError extends Data.TaggedError( + "LegacyEncryptionUnexpectedStatusError", +)<{ + readonly status: number; + readonly body: string; + readonly message: string; +}> {} + +/** + * Build the network/status error mapper for an encryption subcommand. Go uses + * different verbs for the network vs status message of the same subcommand + * (get: "retrieve"/"get"; update: "update"/"update"), so the factory takes + * both and shares the dispatch + body-truncation policy from `mapLegacyHttpError`. + */ +export function mapLegacyEncryptionHttpError(verbs: { + readonly networkVerb: string; // "retrieve" | "update" + readonly statusVerb: string; // "get" | "update" +}) { + return mapLegacyHttpError({ + networkError: LegacyEncryptionNetworkError, + statusError: LegacyEncryptionUnexpectedStatusError, + networkMessage: (cause) => `failed to ${verbs.networkVerb} pgsodium config: ${cause}`, + statusMessage: (status, body) => + `unexpected ${verbs.statusVerb} pgsodium config status ${status}: ${body}`, + }); +} diff --git a/apps/cli/src/legacy/commands/encryption/get-root-key/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/encryption/get-root-key/SIDE_EFFECTS.md deleted file mode 100644 index 7b0300ab8c..0000000000 --- a/apps/cli/src/legacy/commands/encryption/get-root-key/SIDE_EFFECTS.md +++ /dev/null @@ -1,57 +0,0 @@ -# `supabase encryption get-root-key` - -## Files Read - -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------ | ------------ | ------------ | ---------------------- | -| `GET` | `/v1/projects/{ref}/config/database/vault` | Bearer token | none | `{root_key}` | - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | ----------------------------------------------------- | -| `0` | success — root key printed to stdout | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from encryption endpoint | -| `1` | network / connection failure | - -## Output - -### `--output-format text` (Go CLI compatible) - -Prints root encryption key to stdout. - -### `--output-format json` - -Single JSON object emitted to stdout on success. - -### `--output-format stream-json` - -One `result` event on success. - -```ndjson -{"type":"result","data":{...}} -``` - -## Notes - -- Requires `--project-ref` or a linked project (`.supabase/config.json`). diff --git a/apps/cli/src/legacy/commands/encryption/get-root-key/get-root-key.command.ts b/apps/cli/src/legacy/commands/encryption/get-root-key/get-root-key.command.ts index 79ec33e51f..496c2202a6 100644 --- a/apps/cli/src/legacy/commands/encryption/get-root-key/get-root-key.command.ts +++ b/apps/cli/src/legacy/commands/encryption/get-root-key/get-root-key.command.ts @@ -1,5 +1,9 @@ import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyEncryptionGetRootKey } from "./get-root-key.handler.ts"; const config = { @@ -12,7 +16,14 @@ const config = { export type LegacyEncryptionGetRootKeyFlags = CliCommand.Command.Config.Infer; export const legacyEncryptionGetRootKeyCommand = Command.make("get-root-key", config).pipe( - Command.withDescription("Get the root encryption key of a Supabase project."), + Command.withDescription("Get the root encryption key of a Supabase project"), Command.withShortDescription("Get root encryption key"), - Command.withHandler((flags) => legacyEncryptionGetRootKey(flags)), + Command.withHandler((flags) => + legacyEncryptionGetRootKey(flags).pipe( + // `--project-ref` is not telemetry-safe for encryption (no `markFlagTelemetrySafe`). + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(legacyManagementApiRuntimeLayer(["encryption", "get-root-key"])), ); diff --git a/apps/cli/src/legacy/commands/encryption/get-root-key/get-root-key.handler.ts b/apps/cli/src/legacy/commands/encryption/get-root-key/get-root-key.handler.ts index 71d3832dc2..f8acf29b04 100644 --- a/apps/cli/src/legacy/commands/encryption/get-root-key/get-root-key.handler.ts +++ b/apps/cli/src/legacy/commands/encryption/get-root-key/get-root-key.handler.ts @@ -1,12 +1,44 @@ -import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; +import { Effect } from "effect"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { mapLegacyEncryptionHttpError } from "../encryption.errors.ts"; import type { LegacyEncryptionGetRootKeyFlags } from "./get-root-key.command.ts"; +const mapGetError = mapLegacyEncryptionHttpError({ networkVerb: "retrieve", statusVerb: "get" }); + export const legacyEncryptionGetRootKey = Effect.fn("legacy.encryption.get-root-key")(function* ( flags: LegacyEncryptionGetRootKeyFlags, ) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["encryption", "get-root-key"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + const ref = yield* resolver.resolve(flags.projectRef); + + // Mirror Go's PersistentPostRun: write the linked-project cache and persist + // the telemetry state file on success and failure. + yield* Effect.gen(function* () { + const fetching = + output.format === "text" ? yield* output.task("Fetching root key...") : undefined; + const { root_key } = yield* api.v1.getPgsodiumConfig({ ref }).pipe( + Effect.tapError(() => fetching?.fail() ?? Effect.void), + Effect.catch(mapGetError), + ); + yield* fetching?.clear() ?? Effect.void; + + if (output.format !== "text") { + // json / stream-json — emit a structured result. + yield* output.success("", { root_key }); + return; + } + + // text — Go prints the bare key + newline to stdout (`fmt.Println`). + yield* output.raw(root_key + "\n", "stdout"); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/legacy/commands/encryption/get-root-key/get-root-key.integration.test.ts b/apps/cli/src/legacy/commands/encryption/get-root-key/get-root-key.integration.test.ts new file mode 100644 index 0000000000..5e6d17f393 --- /dev/null +++ b/apps/cli/src/legacy/commands/encryption/get-root-key/get-root-key.integration.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Option } from "effect"; + +import { mockOutput } from "../../../../../tests/helpers/mocks.ts"; +import { + buildLegacyTestRuntime, + LEGACY_VALID_REF, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { legacyEncryptionGetRootKey } from "./get-root-key.handler.ts"; + +const ROOT_KEY_RESPONSE = { root_key: "abc123rootkey" }; + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly status?: number; + readonly network?: "fail"; + readonly projectId?: Option.Option; +} + +const tempRoot = useLegacyTempWorkdir("supabase-encryption-get-int-"); + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ format: opts.format ?? "text" }); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 200, body: ROOT_KEY_RESPONSE }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ + workdir: tempRoot.current, + projectId: opts.projectId ?? Option.some(LEGACY_VALID_REF), + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedProjectCache = mockLegacyLinkedProjectCacheTracked(); + const layer = buildLegacyTestRuntime({ + out, + api, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: linkedProjectCache.layer, + }); + return { layer, out, api, telemetry, linkedProjectCache }; +} + +const baseFlags = { projectRef: Option.none() }; + +describe("legacy encryption get-root-key integration", () => { + it.live("prints the root key to stdout in text mode", () => { + const { layer, out } = setup(); + return Effect.gen(function* () { + yield* legacyEncryptionGetRootKey(baseFlags); + expect(out.stdoutText).toBe("abc123rootkey\n"); + expect(out.stderrText).toBe(""); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits the root key as a structured result in json mode", () => { + const { layer, out } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* legacyEncryptionGetRootKey(baseFlags); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.message).toBe(""); + expect(success?.data).toEqual({ root_key: "abc123rootkey" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("streams the root key as a result event in stream-json mode", () => { + const { layer, out } = setup({ format: "stream-json" }); + return Effect.gen(function* () { + yield* legacyEncryptionGetRootKey(baseFlags); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toEqual({ root_key: "abc123rootkey" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("resolves the ref from the --project-ref flag", () => { + const flagRef = "zzzzzzzzzzzzzzzzzzzz"; + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyEncryptionGetRootKey({ projectRef: Option.some(flagRef) }); + expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/pgsodium`); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with a transport error when the network is down", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyEncryptionGetRootKey(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyEncryptionNetworkError"); + expect(json).toContain("failed to retrieve pgsodium config"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with an unexpected-status error on a 503", () => { + const { layer } = setup({ status: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyEncryptionGetRootKey(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyEncryptionUnexpectedStatusError"); + expect(json).toContain("unexpected get pgsodium config status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("does not start a spinner in json mode on failure", () => { + const { layer, out } = setup({ format: "json", status: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyEncryptionGetRootKey(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + expect(out.progressEvents).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when no project ref can be resolved", () => { + const { layer } = setup({ projectId: Option.none() }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyEncryptionGetRootKey(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyProjectNotLinkedError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("writes the linked-project cache and flushes telemetry on success", () => { + const { layer, telemetry, linkedProjectCache } = setup(); + return Effect.gen(function* () { + yield* legacyEncryptionGetRootKey(baseFlags); + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("writes the linked-project cache and flushes telemetry on failure", () => { + const { layer, telemetry, linkedProjectCache } = setup({ status: 503 }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyEncryptionGetRootKey(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/cli/src/legacy/commands/encryption/update-root-key/SIDE_EFFECTS.md b/apps/cli/src/legacy/commands/encryption/update-root-key/SIDE_EFFECTS.md deleted file mode 100644 index 59ff29e739..0000000000 --- a/apps/cli/src/legacy/commands/encryption/update-root-key/SIDE_EFFECTS.md +++ /dev/null @@ -1,57 +0,0 @@ -# `supabase encryption update-root-key` - -## Files Read - -| Path | Format | When | -| -------------------------- | ------------------------- | ---------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | - -## Files Written - -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | - -## API Routes - -| Method | Path | Auth | Request body | Response (used fields) | -| ------- | ------------------------------------------ | ------------ | ------------ | ---------------------- | -| `PATCH` | `/v1/projects/{ref}/config/database/vault` | Bearer token | none | `{root_key}` | - -## Environment Variables - -| Variable | Purpose | Required? | -| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_API_URL` | override Management API base URL | no (defaults to `https://api.supabase.com`) | - -## Exit Codes - -| Code | Condition | -| ---- | ----------------------------------------------------- | -| `0` | success — root key updated | -| `1` | authentication error — no valid token found | -| `1` | API error — non-2xx response from encryption endpoint | -| `1` | network / connection failure | - -## Output - -### `--output-format text` (Go CLI compatible) - -Prints updated root encryption key to stdout. - -### `--output-format json` - -Single JSON object emitted to stdout on success. - -### `--output-format stream-json` - -One `result` event on success. - -```ndjson -{"type":"result","data":{...}} -``` - -## Notes - -- Requires `--project-ref` or a linked project (`.supabase/config.json`). diff --git a/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.command.ts b/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.command.ts index 4c92e8499d..8a0896cea7 100644 --- a/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.command.ts +++ b/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.command.ts @@ -1,5 +1,13 @@ +import { BunServices } from "@effect/platform-bun"; +import { Layer } from "effect"; import { Command, Flag } from "effect/unstable/cli"; import type * as CliCommand from "effect/unstable/cli/Command"; + +import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; +import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; +import { ttyLayer } from "../../../../shared/runtime/tty.layer.ts"; +import { legacyManagementApiRuntimeLayer } from "../../../shared/legacy-management-api-runtime.layer.ts"; +import { withLegacyCommandInstrumentation } from "../../../telemetry/legacy-command-instrumentation.ts"; import { legacyEncryptionUpdateRootKey } from "./update-root-key.handler.ts"; const config = { @@ -11,8 +19,23 @@ const config = { export type LegacyEncryptionUpdateRootKeyFlags = CliCommand.Command.Config.Infer; +// `Stdin` is new production wiring for this command. Provide it explicitly +// (along with its `Tty` + `Stdio` deps) so the command's layer is self-contained +// and does not rely on sibling-layer leakage inside `Layer.mergeAll`. +const updateRuntime = Layer.mergeAll( + legacyManagementApiRuntimeLayer(["encryption", "update-root-key"]), + stdinLayer.pipe(Layer.provide(ttyLayer), Layer.provide(BunServices.layer)), +); + export const legacyEncryptionUpdateRootKeyCommand = Command.make("update-root-key", config).pipe( - Command.withDescription("Update root encryption key of a Supabase project."), + Command.withDescription("Update root encryption key of a Supabase project"), Command.withShortDescription("Update the root encryption key"), - Command.withHandler((flags) => legacyEncryptionUpdateRootKey(flags)), + Command.withHandler((flags) => + legacyEncryptionUpdateRootKey(flags).pipe( + // `--project-ref` is not telemetry-safe for encryption (no `markFlagTelemetrySafe`). + withLegacyCommandInstrumentation({ flags }), + withJsonErrorHandling, + ), + ), + Command.provide(updateRuntime), ); diff --git a/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.handler.ts b/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.handler.ts index 85a56afbe7..2119f0ea52 100644 --- a/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.handler.ts +++ b/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.handler.ts @@ -1,12 +1,67 @@ import { Effect, Option } from "effect"; -import { LegacyGoProxy } from "../../../../shared/legacy/go-proxy.service.ts"; + +import { LegacyPlatformApi } from "../../../auth/legacy-platform-api.service.ts"; +import { LegacyProjectRefResolver } from "../../../config/legacy-project-ref.service.ts"; +import { Output } from "../../../../shared/output/output.service.ts"; +import { Stdin } from "../../../../shared/runtime/stdin.service.ts"; +import { LegacyLinkedProjectCache } from "../../../telemetry/legacy-linked-project-cache.service.ts"; +import { LegacyTelemetryState } from "../../../telemetry/legacy-telemetry-state.service.ts"; +import { mapLegacyEncryptionHttpError } from "../encryption.errors.ts"; import type { LegacyEncryptionUpdateRootKeyFlags } from "./update-root-key.command.ts"; +const mapUpdateError = mapLegacyEncryptionHttpError({ + networkVerb: "update", + statusVerb: "update", +}); + export const legacyEncryptionUpdateRootKey = Effect.fn("legacy.encryption.update-root-key")( function* (flags: LegacyEncryptionUpdateRootKeyFlags) { - const proxy = yield* LegacyGoProxy; - const args: string[] = ["encryption", "update-root-key"]; - if (Option.isSome(flags.projectRef)) args.push("--project-ref", flags.projectRef.value); - yield* proxy.exec(args); + const output = yield* Output; + const api = yield* LegacyPlatformApi; + const resolver = yield* LegacyProjectRefResolver; + const stdin = yield* Stdin; + const linkedProjectCache = yield* LegacyLinkedProjectCache; + const telemetryState = yield* LegacyTelemetryState; + + const ref = yield* resolver.resolve(flags.projectRef); + + // Faithful port of Go's `update.Run` + `credentials.PromptMasked(os.Stdin)`. + // Go unconditionally writes the prompt to stderr, reads the key (masked on a + // TTY, `io.Copy` of all stdin when piped), then prints a trailing newline to + // stdout (`defer fmt.Println()`) — even when stdin is piped. Both read paths + // trim, matching Go's `strings.TrimSpace(input)`. The stderr prompt + stdout + // newline are reproduced only in text mode; json / stream-json reserve stdout + // for the structured result. On a TTY the masked prompt uses clack framing, so + // the rendered prompt is not byte-identical to Go (see SIDE_EFFECTS.md). + let rootKey: string; + if (stdin.isTTY) { + rootKey = yield* output.promptPassword("Enter a new root key: "); + } else { + if (output.format === "text") yield* output.raw("Enter a new root key: ", "stderr"); + rootKey = Option.getOrElse(yield* stdin.readPipedText, () => ""); + if (output.format === "text") yield* output.raw("\n", "stdout"); + } + + // Mirror Go's PersistentPostRun: write the linked-project cache and persist + // the telemetry state file on success and failure. + yield* Effect.gen(function* () { + const updating = + output.format === "text" ? yield* output.task("Updating root key...") : undefined; + const response = yield* api.v1.updatePgsodiumConfig({ ref, root_key: rootKey }).pipe( + Effect.tapError(() => updating?.fail() ?? Effect.void), + Effect.catch(mapUpdateError), + ); + yield* updating?.clear() ?? Effect.void; + + if (output.format !== "text") { + // json / stream-json — emit a structured result. + yield* output.success("", { root_key: response.root_key }); + return; + } + + // text — Go prints a plain finished notice to stderr (`fmt.Fprintln`, + // `utils.Aqua` rendered as plain text per the legacy-port convention). + yield* output.raw("Finished supabase root-key update.\n", "stderr"); + }).pipe(Effect.ensuring(linkedProjectCache.cache(ref)), Effect.ensuring(telemetryState.flush)); }, ); diff --git a/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.integration.test.ts b/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.integration.test.ts new file mode 100644 index 0000000000..ab312812d7 --- /dev/null +++ b/apps/cli/src/legacy/commands/encryption/update-root-key/update-root-key.integration.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Exit, Layer, Option } from "effect"; + +import { mockOutput, mockStdin } from "../../../../../tests/helpers/mocks.ts"; +import { + buildLegacyTestRuntime, + LEGACY_VALID_REF, + mockLegacyCliConfig, + mockLegacyLinkedProjectCacheTracked, + mockLegacyPlatformApi, + mockLegacyTelemetryStateTracked, + useLegacyTempWorkdir, +} from "../../../../../tests/helpers/legacy-mocks.ts"; +import { legacyEncryptionUpdateRootKey } from "./update-root-key.handler.ts"; + +const ROOT_KEY_RESPONSE = { root_key: "new-key" }; + +interface SetupOpts { + readonly format?: "text" | "json" | "stream-json"; + readonly status?: number; + readonly network?: "fail"; + readonly projectId?: Option.Option; + // stdin + readonly stdinIsTty?: boolean; + readonly pipedInput?: string; + readonly promptPasswordResponses?: ReadonlyArray; +} + +const tempRoot = useLegacyTempWorkdir("supabase-encryption-update-int-"); + +function setup(opts: SetupOpts = {}) { + const out = mockOutput({ + format: opts.format ?? "text", + promptPasswordResponses: opts.promptPasswordResponses, + }); + const api = mockLegacyPlatformApi({ + response: { status: opts.status ?? 200, body: ROOT_KEY_RESPONSE }, + network: opts.network, + }); + const cliConfig = mockLegacyCliConfig({ + workdir: tempRoot.current, + projectId: opts.projectId ?? Option.some(LEGACY_VALID_REF), + }); + const telemetry = mockLegacyTelemetryStateTracked(); + const linkedProjectCache = mockLegacyLinkedProjectCacheTracked(); + const layer = Layer.mergeAll( + buildLegacyTestRuntime({ + out, + api, + cliConfig, + telemetry: telemetry.layer, + linkedProjectCache: linkedProjectCache.layer, + }), + mockStdin(opts.stdinIsTty ?? false, opts.pipedInput), + ); + return { layer, out, api, telemetry, linkedProjectCache }; +} + +const baseFlags = { projectRef: Option.none() }; + +describe("legacy encryption update-root-key integration", () => { + it.live("reads a piped root key and PUTs it, printing the finished message to stderr", () => { + const { layer, out, api } = setup({ pipedInput: "new-key" }); + return Effect.gen(function* () { + yield* legacyEncryptionUpdateRootKey(baseFlags); + const put = api.requests.find((r) => r.method === "PUT"); + expect(put?.url).toContain(`/v1/projects/${LEGACY_VALID_REF}/pgsodium`); + expect(put?.body).toEqual({ root_key: "new-key" }); + // Go parity: prompt to stderr, trailing newline to stdout (defer Println), + // finished notice to stderr. + expect(out.stderrText).toContain("Enter a new root key: "); + expect(out.stderrText).toContain("Finished supabase root-key update."); + expect(out.stdoutText).toBe("\n"); + }).pipe(Effect.provide(layer)); + }); + + it.live("prompts for a masked root key when stdin is a TTY", () => { + const { layer, api } = setup({ + stdinIsTty: true, + promptPasswordResponses: ["tty-key"], + }); + return Effect.gen(function* () { + yield* legacyEncryptionUpdateRootKey(baseFlags); + const put = api.requests.find((r) => r.method === "PUT"); + expect(put?.body).toEqual({ root_key: "tty-key" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("sends an empty root key when piped stdin is empty", () => { + const { layer, api } = setup(); + return Effect.gen(function* () { + yield* legacyEncryptionUpdateRootKey(baseFlags); + const put = api.requests.find((r) => r.method === "PUT"); + expect(put?.body).toEqual({ root_key: "" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits the updated config as a structured result in json mode", () => { + const { layer, out } = setup({ format: "json", pipedInput: "new-key" }); + return Effect.gen(function* () { + yield* legacyEncryptionUpdateRootKey(baseFlags); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.message).toBe(""); + expect(success?.data).toEqual({ root_key: "new-key" }); + // json mode reserves stdout for the structured result — no prompt newline. + expect(out.stdoutText).toBe(""); + expect(out.stderrText).toBe(""); + }).pipe(Effect.provide(layer)); + }); + + it.live("emits a result event in stream-json mode", () => { + const { layer, out } = setup({ format: "stream-json", pipedInput: "new-key" }); + return Effect.gen(function* () { + yield* legacyEncryptionUpdateRootKey(baseFlags); + const success = out.messages.find((m) => m.type === "success"); + expect(success?.data).toEqual({ root_key: "new-key" }); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with a transport error when the network is down", () => { + const { layer } = setup({ network: "fail", pipedInput: "new-key" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyEncryptionUpdateRootKey(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyEncryptionNetworkError"); + expect(json).toContain("failed to update pgsodium config"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("fails with an unexpected-status error on a 503", () => { + const { layer } = setup({ status: 503, pipedInput: "new-key" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyEncryptionUpdateRootKey(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const json = JSON.stringify(exit.cause); + expect(json).toContain("LegacyEncryptionUnexpectedStatusError"); + expect(json).toContain("unexpected update pgsodium config status 503"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("does not start a spinner in json mode on failure", () => { + const { layer, out } = setup({ format: "json", status: 503, pipedInput: "new-key" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyEncryptionUpdateRootKey(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + expect(out.progressEvents).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.live("fails when no project ref can be resolved", () => { + const { layer } = setup({ projectId: Option.none(), pipedInput: "new-key" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyEncryptionUpdateRootKey(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(JSON.stringify(exit.cause)).toContain("LegacyProjectNotLinkedError"); + } + }).pipe(Effect.provide(layer)); + }); + + it.live("writes the linked-project cache and flushes telemetry on success", () => { + const { layer, telemetry, linkedProjectCache } = setup({ pipedInput: "new-key" }); + return Effect.gen(function* () { + yield* legacyEncryptionUpdateRootKey(baseFlags); + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); + + it.live("writes the linked-project cache and flushes telemetry on failure", () => { + const { layer, telemetry, linkedProjectCache } = setup({ + status: 503, + pipedInput: "new-key", + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(legacyEncryptionUpdateRootKey(baseFlags)); + expect(Exit.isFailure(exit)).toBe(true); + expect(telemetry.flushed).toBe(true); + expect(linkedProjectCache.cached).toBe(true); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/packages/cli-test-helpers/src/normalize.ts b/packages/cli-test-helpers/src/normalize.ts index 12435a0dba..8851844b6e 100644 --- a/packages/cli-test-helpers/src/normalize.ts +++ b/packages/cli-test-helpers/src/normalize.ts @@ -78,6 +78,13 @@ export function normalize(output: string): string { // The TS port intentionally doesn't reconstruct these — strip the // frame block plus the trailing blank line so parity comparisons ignore them. .replace(/(?:^ \(0xADDR\)\n\t[^\n]+\n)+\n?/gm, "") + // 12c. A go-errors frame glued to a preceding prompt on the same line, e.g. + // `Enter a new root key: (0xADDR)\n\t: …`. Rule 12b + // only strips frames that begin at line start, so when a command writes + // a prompt to stderr without a trailing newline (`encryption update-root-key`), + // the first frame stays glued to the prompt and survives. Strip that + // residual frame too, leaving just the prompt text. + .replace(/ \(0xADDR\)\n\t[^\n]+\n/g, "") // 13. Node/Bun stack trace lines (one or more consecutive " at …" lines) .replace(/(?:^[ \t]+at [^\n]+\n?)+/gm, "\n") // 14. File reference line numbers (file.ts:123 or file.ts:123:45)