Skip to content

Commit 910b640

Browse files
committed
feat(cli): central entitlement_required envelope handler
1 parent 119966b commit 910b640

38 files changed

Lines changed: 1036 additions & 432 deletions

apps/cli/AGENTS.md

Lines changed: 48 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,48 @@ unchanged — see [Legacy Port Status and Go CLI Authority](#legacy-port-status-
306306

307307
---
308308

309+
## Plan-gate envelope and the JSON error contract
310+
311+
The management API marks entitlement denials with a structured envelope in the error body:
312+
`{ "message": "...", "error": { "code": "entitlement_required", "feature": "<key>", "upgrade_url": "<billing url>" } }`.
313+
314+
Handling is CENTRAL — never add per-command wiring for envelope-carrying gates:
315+
316+
- `mapLegacyHttpError` (`src/legacy/shared/legacy-http-errors.ts`) parses the envelope from the
317+
raw response text (pre-truncation), attaches optional `entitlement` + `suggestion` +
318+
`upgradeSuggested` fields to the tagged status error, and fires `cli_upgrade_suggested` exactly
319+
once per denial (`trackUpgradeSuggested: false` opts out — vanity check-availability, Go
320+
parity). `upgradeSuggested` feeds `statusCodeActionability` (see Error Classification): an
321+
error class on a gated route declares the optional field and passes it through, and denials
322+
classify as `plan_limit` with zero per-command wiring. For an envelope-less gate confirmed by
323+
the fallback, callers pass the boolean per call: `mapper(cause, { upgradeSuggested })`.
324+
- The shared contract module is `src/shared/api/plan-gate.ts` (`PlanGateEntitlement`, parser,
325+
`errorEntitlement` reader, hint prose). The next shell must reuse it and emit the identical
326+
field shape.
327+
- Output: text mode prints the upgrade hint centrally in `textOutputLayer.fail` when the
328+
normalized error carries `entitlement` (hint, then red message, then the --debug line);
329+
json / stream-json carry the fields on the error object:
330+
`{ "_tag": "Error", "error": { "code", "message", "suggestion", "entitlement": { "feature", "upgrade_url" } } }`.
331+
`entitlement` presence is the machine-readable discriminator; consumers must treat it as an
332+
optional enhancement (absent on envelope-less servers and older CLI versions).
333+
- `legacySuggestUpgrade` (`src/legacy/shared/legacy-upgrade-suggest.ts`) is only the
334+
entitlements-lookup FALLBACK for envelope-less denials (v1 SSO, older servers). When the
335+
response carries an envelope it returns the confirmed-gated boolean but performs no side
336+
effects — hint, telemetry, and error fields are the central handler's. New commands must not
337+
call it for envelope-emitting routes.
338+
- Known central-handler bypasses (all dormant while v1 SSO emits no envelope; fix when the routes
339+
gain it): `sso add` POST and `sso update` PUT construct status errors without
340+
`mapLegacyHttpError` (no attach), and `sso list`/`sso remove`/`sso show`/`sso update`'s GET
341+
swap the mapped error for a bare replacement error on 404 (fields discarded — `NotFoundError`
342+
variants; `list` uses `SamlDisabledError`). Route these through `mapLegacyHttpError` (or attach
343+
via `src/shared/api/plan-gate.ts`) before enveloping SSO server-side.
344+
- Go divergence (deliberate, 2026-07-28): the Go binary kept per-site `SuggestUpgradeOnError`
345+
wiring; the TS handler is central. Consistent with
346+
[Legacy Port Status and Go CLI Authority](#legacy-port-status-and-go-cli-authority), the 1:1
347+
parity doctrine covers this subsystem's user-visible output only, not its internal structure.
348+
349+
---
350+
309351
## Legacy Port: Go Parity Checklist
310352

311353
When porting a Management-API-style command, verify each item before marking the command as `ported`:
@@ -346,12 +388,12 @@ The legacy shell sends the same PostHog events to the same product analytics pip
346388
- **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`.
347389
- **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):
348390

349-
| Command | Event | Identity / groups | Go source |
350-
| --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
351-
| `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) |
352-
| `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) |
353-
| `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) |
354-
| `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) |
391+
| Command | Event | Identity / groups | Go source |
392+
| --------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- |
393+
| `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) |
394+
| `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) |
395+
| `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) |
396+
| `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) |
355397

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

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import { promises as fs } from "node:fs";
2+
import os from "node:os";
3+
import path from "node:path";
4+
5+
import { describe, expect, test } from "vitest";
6+
import { runSupabase, stripAnsi } from "../../../tests/helpers/cli.ts";
7+
8+
function parseJsonLines(output: string): Array<unknown> {
9+
return stripAnsi(output)
10+
.trim()
11+
.split("\n")
12+
.filter((line) => line.length > 0)
13+
.map((line) => JSON.parse(line));
14+
}
15+
16+
function gatedEnvelope(feature: string) {
17+
return {
18+
message: "This feature requires a paid plan",
19+
error: {
20+
code: "entitlement_required",
21+
feature,
22+
upgrade_url: "https://supabase.com/dashboard/org/env-org/billing",
23+
},
24+
};
25+
}
26+
27+
async function withGatedApiStub<T>(
28+
feature: string,
29+
run: (env: Record<string, string>) => Promise<T>,
30+
): Promise<T> {
31+
const server = Bun.serve({
32+
port: 0,
33+
fetch: () => Response.json(gatedEnvelope(feature), { status: 403 }),
34+
});
35+
const profileDir = await fs.mkdtemp(path.join(os.tmpdir(), "supabase-e2e-profile-"));
36+
const profilePath = path.join(profileDir, "profile.yaml");
37+
await fs.writeFile(profilePath, `api_url: http://127.0.0.1:${server.port}\n`);
38+
39+
try {
40+
return await run({
41+
SUPABASE_PROFILE: profilePath,
42+
SUPABASE_ACCESS_TOKEN: `sbp_${"a".repeat(40)}`,
43+
});
44+
} finally {
45+
server.stop(true);
46+
await fs.rm(profileDir, { recursive: true, force: true });
47+
}
48+
}
49+
50+
describe("legacy CLI plan-gate error output", () => {
51+
test("carries entitlement on the JSON error for a gated denial", async () => {
52+
await withGatedApiStub("physical_backups", async (env) => {
53+
const result = await runSupabase(
54+
["backups", "list", "--project-ref", "abcdefghijklmnopqrst", "--output-format", "json"],
55+
{ entrypoint: "legacy", env },
56+
);
57+
expect(result.exitCode).not.toBe(0);
58+
expect(result.stderr).not.toContain("Upgrade your plan:");
59+
expect(parseJsonLines(result.stdout)).toEqual([
60+
expect.objectContaining({
61+
_tag: "Error",
62+
error: expect.objectContaining({
63+
entitlement: {
64+
feature: "physical_backups",
65+
upgrade_url: "https://supabase.com/dashboard/org/env-org/billing",
66+
},
67+
suggestion: expect.stringContaining(
68+
"https://supabase.com/dashboard/org/env-org/billing",
69+
),
70+
}),
71+
}),
72+
]);
73+
});
74+
});
75+
76+
test("prints the text-mode hint exactly once for a gated denial with no per-command wiring", async () => {
77+
await withGatedApiStub("physical_backups", async (env) => {
78+
const result = await runSupabase(
79+
["backups", "list", "--project-ref", "abcdefghijklmnopqrst"],
80+
{
81+
entrypoint: "legacy",
82+
env,
83+
},
84+
);
85+
expect(result.exitCode).not.toBe(0);
86+
const stderr = stripAnsi(result.stderr);
87+
expect(stderr.split("Upgrade your plan:").length - 1).toBe(1);
88+
expect(stderr.indexOf("Upgrade your plan:")).toBeLessThan(
89+
stderr.indexOf("unexpected list backup status 403"),
90+
);
91+
expect(stderr).toContain("Try rerunning the command with --debug");
92+
});
93+
});
94+
95+
test("prints the hint exactly once for a previously per-site-wired gated command", async () => {
96+
await withGatedApiStub("custom_domain", async (env) => {
97+
const result = await runSupabase(
98+
["domains", "get", "--project-ref", "abcdefghijklmnopqrst"],
99+
{
100+
entrypoint: "legacy",
101+
env,
102+
},
103+
);
104+
expect(result.exitCode).not.toBe(0);
105+
const stderr = stripAnsi(result.stderr);
106+
expect(stderr.split("Upgrade your plan:").length - 1).toBe(1);
107+
expect(stderr).toContain("unexpected get hostname status 403");
108+
});
109+
});
110+
});

apps/cli/src/legacy/commands/backups/list/list.integration.test.ts

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,9 @@ import { type V1ListAllBackupsOutput } from "@supabase/api/effect";
66
import { describe, expect, it } from "@effect/vitest";
77
import { Effect, Exit, Option } from "effect";
88

9+
import { errorEntitlement } from "../../../../shared/api/plan-gate.ts";
910
import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts";
10-
import { mockOutput } from "../../../../../tests/helpers/mocks.ts";
11+
import { mockAnalytics, mockOutput } from "../../../../../tests/helpers/mocks.ts";
1112
import {
1213
LEGACY_VALID_REF,
1314
buildLegacyTestRuntime,
@@ -43,7 +44,7 @@ const LOGICAL_RESPONSE: typeof V1ListAllBackupsOutput.Type = {
4344
interface SetupOpts {
4445
format?: "text" | "json" | "stream-json";
4546
goOutput?: "env" | "pretty" | "json" | "toml" | "yaml";
46-
response?: typeof V1ListAllBackupsOutput.Type;
47+
response?: unknown;
4748
status?: number;
4849
network?: "fail";
4950
apiUrl?: string;
@@ -54,6 +55,7 @@ const tempRoot = useLegacyTempWorkdir("supabase-backups-list-int-");
5455

5556
function setup(opts: SetupOpts = {}) {
5657
const out = mockOutput({ format: opts.format ?? "text" });
58+
const analytics = mockAnalytics();
5759
const api = mockLegacyPlatformApi({
5860
response: { status: opts.status ?? 200, body: opts.response ?? PITR_RESPONSE },
5961
network: opts.network,
@@ -69,9 +71,10 @@ function setup(opts: SetupOpts = {}) {
6971
out,
7072
api,
7173
cliConfig,
74+
analytics,
7275
goOutput: opts.goOutput === undefined ? Option.none() : Option.some(opts.goOutput),
7376
});
74-
return { layer, out, api };
77+
return { layer, out, api, analytics };
7578
}
7679

7780
describe("legacy backups list integration", () => {
@@ -343,4 +346,38 @@ WalgEnabled = true
343346
}).pipe(Effect.provide(layer));
344347
},
345348
);
349+
350+
it.live(
351+
"carries entitlement and fires central telemetry on a gated denial with zero wiring",
352+
() => {
353+
const { layer, out, analytics } = setup({
354+
status: 403,
355+
response: {
356+
message: "Physical backups require the Pro plan",
357+
error: {
358+
code: "entitlement_required",
359+
feature: "physical_backups",
360+
upgrade_url: "https://supabase.com/dashboard/org/env-org/billing",
361+
},
362+
},
363+
});
364+
return Effect.gen(function* () {
365+
const exit = yield* Effect.exit(
366+
legacyBackupsList({ projectRef: Option.some(LEGACY_VALID_REF) }),
367+
);
368+
expect(Exit.isFailure(exit)).toBe(true);
369+
expect(errorEntitlement(Option.getOrUndefined(Exit.findErrorOption(exit)))).toEqual({
370+
feature: "physical_backups",
371+
upgrade_url: "https://supabase.com/dashboard/org/env-org/billing",
372+
});
373+
expect(out.stderrText).not.toContain("Upgrade your plan:");
374+
expect(analytics.captured).toEqual([
375+
{
376+
event: "cli_upgrade_suggested",
377+
properties: { feature_key: "physical_backups", org_slug: "env-org" },
378+
},
379+
]);
380+
}).pipe(Effect.provide(layer));
381+
},
382+
);
346383
});

apps/cli/src/legacy/commands/branches/create/create.handler.ts

Lines changed: 1 addition & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -117,21 +117,7 @@ export const legacyBranchesCreate = Effect.fn("legacy.branches.create")(function
117117
Effect.catch(
118118
legacyGateMapError(
119119
{ projectRef: ref, featureKey: "branching_limit" },
120-
(cause, upgradeSuggested) =>
121-
Effect.gen(function* () {
122-
const mapped = yield* Effect.flip(mapCreateErrorRaw(cause));
123-
if (mapped._tag === "LegacyBranchesCreateUnexpectedStatusError") {
124-
return yield* Effect.fail(
125-
new LegacyBranchesCreateUnexpectedStatusError({
126-
status: mapped.status,
127-
body: mapped.body,
128-
message: mapped.message,
129-
upgradeSuggested,
130-
}),
131-
);
132-
}
133-
return yield* Effect.fail(mapped);
134-
}),
120+
(cause, upgradeSuggested) => mapCreateErrorRaw(cause, { upgradeSuggested }),
135121
),
136122
),
137123
);

0 commit comments

Comments
 (0)