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
4 changes: 2 additions & 2 deletions apps/cli/docs/go-cli-porting-status.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
88 changes: 88 additions & 0 deletions apps/cli/src/legacy/commands/encryption/SIDE_EFFECTS.md
Original file line number Diff line number Diff line change
@@ -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/<workdir-hash>/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/<workdir-hash>/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.
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
39 changes: 39 additions & 0 deletions apps/cli/src/legacy/commands/encryption/encryption.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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");
},
);
});
44 changes: 44 additions & 0 deletions apps/cli/src/legacy/commands/encryption/encryption.errors.ts
Original file line number Diff line number Diff line change
@@ -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 <verb> 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 <verb> 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}`,
});
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -12,7 +16,14 @@ const config = {
export type LegacyEncryptionGetRootKeyFlags = CliCommand.Command.Config.Infer<typeof config>;

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"])),
);
Original file line number Diff line number Diff line change
@@ -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));
});
Loading
Loading