Skip to content
Draft
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
54 changes: 48 additions & 6 deletions apps/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,48 @@ unchanged — see [Legacy Port Status and Go CLI Authority](#legacy-port-status-

---

## Plan-gate envelope and the JSON error contract

The management API marks entitlement denials with a structured envelope in the error body:
`{ "message": "...", "error": { "code": "entitlement_required", "feature": "<key>", "upgrade_url": "<billing url>" } }`.

Handling is CENTRAL — never add per-command wiring for envelope-carrying gates:

- `mapLegacyHttpError` (`src/legacy/shared/legacy-http-errors.ts`) parses the envelope from the
raw response text (pre-truncation), attaches optional `entitlement` + `suggestion` +
`upgradeSuggested` fields to the tagged status error, and fires `cli_upgrade_suggested` exactly
once per denial (`trackUpgradeSuggested: false` opts out — vanity check-availability, Go
parity). `upgradeSuggested` feeds `statusCodeActionability` (see Error Classification): an
error class on a gated route declares the optional field and passes it through, and denials
classify as `plan_limit` with zero per-command wiring. For an envelope-less gate confirmed by
the fallback, callers pass the boolean per call: `mapper(cause, { upgradeSuggested })`.
- The shared contract module is `src/shared/api/plan-gate.ts` (`PlanGateEntitlement`, parser,
`errorEntitlement` reader, hint prose). The next shell must reuse it and emit the identical
field shape.
- Output: text mode prints the upgrade hint centrally in `textOutputLayer.fail` when the
normalized error carries `entitlement` (hint, then red message, then the --debug line);
json / stream-json carry the fields on the error object:
`{ "_tag": "Error", "error": { "code", "message", "suggestion", "entitlement": { "feature", "upgrade_url" } } }`.
`entitlement` presence is the machine-readable discriminator; consumers must treat it as an
optional enhancement (absent on envelope-less servers and older CLI versions).
- `legacySuggestUpgrade` (`src/legacy/shared/legacy-upgrade-suggest.ts`) is only the
entitlements-lookup FALLBACK for envelope-less denials (v1 SSO, older servers). When the
response carries an envelope it returns the confirmed-gated boolean but performs no side
effects — hint, telemetry, and error fields are the central handler's. New commands must not
call it for envelope-emitting routes.
- Known central-handler bypasses (all dormant while v1 SSO emits no envelope; fix when the routes
gain it): `sso add` POST and `sso update` PUT construct status errors without
`mapLegacyHttpError` (no attach), and `sso list`/`sso remove`/`sso show`/`sso update`'s GET
swap the mapped error for a bare replacement error on 404 (fields discarded — `NotFoundError`
variants; `list` uses `SamlDisabledError`). Route these through `mapLegacyHttpError` (or attach
via `src/shared/api/plan-gate.ts`) before enveloping SSO server-side.
- Go divergence (deliberate, 2026-07-28): the Go binary kept per-site `SuggestUpgradeOnError`
wiring; the TS handler is central. Consistent with
[Legacy Port Status and Go CLI Authority](#legacy-port-status-and-go-cli-authority), the 1:1
parity doctrine covers this subsystem's user-visible output only, not its internal structure.

---

## Legacy Port: Go Parity Checklist

When porting a Management-API-style command, verify each item before marking the command as `ported`:
Expand Down Expand Up @@ -346,12 +388,12 @@ The legacy shell sends the same PostHog events to the same product analytics pip
- **Proxy handlers (`LegacyGoProxy.exec`) must NOT wrap with any instrumentation.** The Go subprocess fires its own telemetry; a TS wrapper would double-count `cli_command_executed`.
- **When promoting a command from proxy to native, reproduce every `phtelemetry.*` call in the Go counterpart.** Grep `apps/cli-go/internal/<command>/` for `service.Capture`, `service.Alias`, `service.Identify`, `service.GroupIdentify`, and `TrackUpgradeSuggested` — note that most `internal/<command>/` packages were deleted in CLI-1970 once their commands went fully native, so this grep only finds something for the still-`wrapped` commands; check out commit `7b469f5b3` to grep an already-ported command's former Go source. The current Go custom events that legacy ports must reproduce when natively ported (already captured below, so this is only needed for a command not yet in this table):

| Command | Event | Identity / groups | Go source |
| --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `login` | `cli_login_completed` | `analytics.alias(gotrueId, deviceId)` after token persists | `internal/login/login.go:283-296` (deleted in CLI-1970; last present at commit 7b469f5b3) |
| `link` | `cli_project_linked` | `analytics.groupIdentify("organization", slug, …)` + `analytics.groupIdentify("project", ref, …)` after link write | `internal/link/link.go:60` (deleted in CLI-1970; last present at commit 7b469f5b3) |
| `start` | `cli_stack_started` | none — fired after stack health check passes | formerly `internal/start/start.go:1245` (deleted as unreachable in CLI-1966; last present at commit a253ccba2) |
| `sso/{list,create,update,remove}`, `branches/{create,update}`, `hostnames/{create,activate,get,reverify}`, `vanity_subdomains/{activate,get}` | `cli_upgrade_suggested` | none — payload is `{feature_key, org_slug}`, fired inside billing-gate error branch (`SuggestUpgradeOnError` is envelope-first; hostnames + vanity get are envelope-only) | call-sites under `internal/{sso,branches,hostnames,vanity_subdomains}/` (deleted in CLI-1970; last present at commit 7b469f5b3) |
| Command | Event | Identity / groups | Go source |
| --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
| `login` | `cli_login_completed` | `analytics.alias(gotrueId, deviceId)` after token persists | `internal/login/login.go:283-296` (deleted in CLI-1970; last present at commit 7b469f5b3) |
| `link` | `cli_project_linked` | `analytics.groupIdentify("organization", slug, …)` + `analytics.groupIdentify("project", ref, …)` after link write | `internal/link/link.go:60` (deleted in CLI-1970; last present at commit 7b469f5b3) |
| `start` | `cli_stack_started` | none — fired after stack health check passes | formerly `internal/start/start.go:1245` (deleted as unreachable in CLI-1966; last present at commit a253ccba2) |
| `sso/{list,create,update,remove}`, `branches/{create,update}`, `hostnames/{create,activate,get,reverify}`, `vanity_subdomains/{activate,get}` | `cli_upgrade_suggested` | none — payload is `{feature_key, org_slug}`. TS divergence (deliberate): envelope denials fire centrally at envelope parse in `mapLegacyHttpError` (feature from the envelope, org from `upgrade_url`; check-availability suppression = `trackUpgradeSuggested: false` on its mapper); the per-site `legacySuggestUpgrade` fallback fires only for envelope-less denials. Go stays per-site (`SuggestUpgradeOnError`, envelope-first). | call-sites under `internal/{sso,branches,hostnames,vanity_subdomains}/` (deleted in CLI-1970; last present at commit 7b469f5b3) |

Reference pattern for login: `next/commands/login/login.handler.ts:38-62`.

Expand Down
110 changes: 110 additions & 0 deletions apps/cli/src/legacy/cli/plan-gate-output.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";

import { describe, expect, test } from "vitest";
import { runSupabase, stripAnsi } from "../../../tests/helpers/cli.ts";

function parseJsonLines(output: string): Array<unknown> {
return stripAnsi(output)
.trim()
.split("\n")
.filter((line) => line.length > 0)
.map((line) => JSON.parse(line));
}

function gatedEnvelope(feature: string) {
return {
message: "This feature requires a paid plan",
error: {
code: "entitlement_required",
feature,
upgrade_url: "https://supabase.com/dashboard/org/env-org/billing",
},
};
}

async function withGatedApiStub<T>(
feature: string,
run: (env: Record<string, string>) => Promise<T>,
): Promise<T> {
const server = Bun.serve({
port: 0,
fetch: () => Response.json(gatedEnvelope(feature), { status: 403 }),
});
const profileDir = await fs.mkdtemp(path.join(os.tmpdir(), "supabase-e2e-profile-"));
const profilePath = path.join(profileDir, "profile.yaml");
await fs.writeFile(profilePath, `api_url: http://127.0.0.1:${server.port}\n`);

try {
return await run({
SUPABASE_PROFILE: profilePath,
SUPABASE_ACCESS_TOKEN: `sbp_${"a".repeat(40)}`,
});
} finally {
server.stop(true);
await fs.rm(profileDir, { recursive: true, force: true });
}
}

describe("legacy CLI plan-gate error output", () => {
test("carries entitlement on the JSON error for a gated denial", async () => {
await withGatedApiStub("physical_backups", async (env) => {
const result = await runSupabase(
["backups", "list", "--project-ref", "abcdefghijklmnopqrst", "--output-format", "json"],
{ entrypoint: "legacy", env },
);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).not.toContain("Upgrade your plan:");
expect(parseJsonLines(result.stdout)).toEqual([
expect.objectContaining({
_tag: "Error",
error: expect.objectContaining({
entitlement: {
feature: "physical_backups",
upgrade_url: "https://supabase.com/dashboard/org/env-org/billing",
},
suggestion: expect.stringContaining(
"https://supabase.com/dashboard/org/env-org/billing",
),
}),
}),
]);
});
});

test("prints the text-mode hint exactly once for a gated denial with no per-command wiring", async () => {
await withGatedApiStub("physical_backups", async (env) => {
const result = await runSupabase(
["backups", "list", "--project-ref", "abcdefghijklmnopqrst"],
{
entrypoint: "legacy",
env,
},
);
expect(result.exitCode).not.toBe(0);
const stderr = stripAnsi(result.stderr);
expect(stderr.split("Upgrade your plan:").length - 1).toBe(1);
expect(stderr.indexOf("Upgrade your plan:")).toBeLessThan(
stderr.indexOf("unexpected list backup status 403"),
);
expect(stderr).toContain("Try rerunning the command with --debug");
});
});

test("prints the hint exactly once for a previously per-site-wired gated command", async () => {
await withGatedApiStub("custom_domain", async (env) => {
const result = await runSupabase(
["domains", "get", "--project-ref", "abcdefghijklmnopqrst"],
{
entrypoint: "legacy",
env,
},
);
expect(result.exitCode).not.toBe(0);
const stderr = stripAnsi(result.stderr);
expect(stderr.split("Upgrade your plan:").length - 1).toBe(1);
expect(stderr).toContain("unexpected get hostname status 403");
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,9 @@ import { type V1ListAllBackupsOutput } from "@supabase/api/effect";
import { describe, expect, it } from "@effect/vitest";
import { Effect, Exit, Option } from "effect";

import { errorEntitlement } from "../../../../shared/api/plan-gate.ts";
import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts";
import { mockOutput } from "../../../../../tests/helpers/mocks.ts";
import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts";
import {
LEGACY_VALID_REF,
buildLegacyTestRuntime,
Expand Down Expand Up @@ -43,7 +44,7 @@ const LOGICAL_RESPONSE: typeof V1ListAllBackupsOutput.Type = {
interface SetupOpts {
format?: "text" | "json" | "stream-json";
goOutput?: "env" | "pretty" | "json" | "toml" | "yaml";
response?: typeof V1ListAllBackupsOutput.Type;
response?: unknown;
status?: number;
network?: "fail";
apiUrl?: string;
Expand All @@ -54,6 +55,7 @@ const tempRoot = useLegacyTempWorkdir("supabase-backups-list-int-");

function setup(opts: SetupOpts = {}) {
const out = mockOutput({ format: opts.format ?? "text" });
const analytics = mockAnalytics();
const api = mockLegacyPlatformApi({
response: { status: opts.status ?? 200, body: opts.response ?? PITR_RESPONSE },
network: opts.network,
Expand All @@ -69,9 +71,10 @@ function setup(opts: SetupOpts = {}) {
out,
api,
cliConfig,
analytics,
goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput),
});
return { layer, out, api };
return { layer, out, api, analytics };
}

describe("legacy backups list integration", () => {
Expand Down Expand Up @@ -343,4 +346,38 @@ WalgEnabled = true
}).pipe(Effect.provide(layer));
},
);

it.live(
"carries entitlement and fires central telemetry on a gated denial with zero wiring",
() => {
const { layer, out, analytics } = setup({
status: 403,
response: {
message: "Physical backups require the Pro plan",
error: {
code: "entitlement_required",
feature: "physical_backups",
upgrade_url: "https://supabase.com/dashboard/org/env-org/billing",
},
},
});
return Effect.gen(function* () {
const exit = yield* Effect.exit(
legacyBackupsList({ projectRef: Option.some(LEGACY_VALID_REF) }),
);
expect(Exit.isFailure(exit)).toBe(true);
expect(errorEntitlement(Option.getOrUndefined(Exit.findErrorOption(exit)))).toEqual({
feature: "physical_backups",
upgrade_url: "https://supabase.com/dashboard/org/env-org/billing",
});
expect(out.stderrText).not.toContain("Upgrade your plan:");
expect(analytics.captured).toEqual([
{
event: "cli_upgrade_suggested",
properties: { feature_key: "physical_backups", org_slug: "env-org" },
},
]);
}).pipe(Effect.provide(layer));
},
);
});
16 changes: 1 addition & 15 deletions apps/cli/src/legacy/commands/branches/create/create.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,21 +117,7 @@ export const legacyBranchesCreate = Effect.fn("legacy.branches.create")(function
Effect.catch(
legacyGateMapError(
{ projectRef: ref, featureKey: "branching_limit" },
(cause, upgradeSuggested) =>
Effect.gen(function* () {
const mapped = yield* Effect.flip(mapCreateErrorRaw(cause));
if (mapped._tag === "LegacyBranchesCreateUnexpectedStatusError") {
return yield* Effect.fail(
new LegacyBranchesCreateUnexpectedStatusError({
status: mapped.status,
body: mapped.body,
message: mapped.message,
upgradeSuggested,
}),
);
}
return yield* Effect.fail(mapped);
}),
(cause, upgradeSuggested) => mapCreateErrorRaw(cause, { upgradeSuggested }),
),
),
);
Expand Down
Loading
Loading