Skip to content
4 changes: 4 additions & 0 deletions apps/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,10 @@ text layer. Therefore stdout is payload-only for either machine flag. Do not cha
layer to move spinners; that would change text output globally. Handlers check `--output` first and
use established encoders before the `output.format` branch.

Plan-gated denials carry an optional `entitlement: { feature, upgrade_url }` on the json and
stream-json error object. `mapHttpError` attaches it from the Management API's
`entitlement_required` body, so a handler that rebuilds a mapped status error must forward it.

## Tests

Follow the root testing philosophy and use the package scripts in `package.json`. Cover meaningful
Expand Down
45 changes: 45 additions & 0 deletions apps/cli/src/command-internal/http-errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { Effect } from "effect";
import * as HttpBody from "effect/http/HttpBody";
import * as HttpClientError from "effect/http/HttpClientError";

import type { PlanGateEntitlement } from "../shared/output/normalize-error.ts";

// HttpClientError reasons that indicate the server returned an actual response (vs a transport
// failure). Anything in this set surfaces as an `UnexpectedStatusError`; everything else maps
// to a `NetworkError`.
Expand Down Expand Up @@ -95,8 +97,44 @@ export type StatusErrorFactory<E> = new (args: {
readonly status: number;
readonly body: string;
readonly message: string;
readonly entitlement?: PlanGateEntitlement;
}) => E;

const FEATURE_KEY_PATTERN = /^[\w.-]{1,128}$/;
const MAX_UPGRADE_URL_LEN = 2048;

function readString(obj: unknown, key: string): string {
if (typeof obj === "object" && obj !== null && key in obj) {
const value = (obj as Record<string, unknown>)[key];
return typeof value === "string" ? value : "";
}
return "";
}

// The parsed `href` percent-encodes control and bidi characters, so the URL is safe to print.
function normalizeUpgradeUrl(raw: string): string | undefined {
if (raw.length > MAX_UPGRADE_URL_LEN || !URL.canParse(raw)) return undefined;
const url = new URL(raw);
const isWebUrl = url.protocol === "https:" || url.protocol === "http:";
const hasCredentials = url.username !== "" || url.password !== "";
return isWebUrl && !hasCredentials ? url.href : undefined;
}

/**
* Reads the Management API's plan-gate error body,
* `{ error: { code: "entitlement_required", feature, upgrade_url } }`.
* Hand-validated: the packages/api codegen emits no non-2xx schemas.
*/
export function parsePlanGateEnvelope(body: unknown): PlanGateEntitlement | undefined {
if (typeof body !== "object" || body === null) return undefined;
const error = (body as { readonly error?: unknown }).error;
if (readString(error, "code") !== "entitlement_required") return undefined;
const feature = readString(error, "feature");
const upgradeUrl = normalizeUpgradeUrl(readString(error, "upgrade_url"));
if (!FEATURE_KEY_PATTERN.test(feature) || upgradeUrl === undefined) return undefined;
return { feature, upgrade_url: upgradeUrl };
}

/**
* Builds an error mapper that classifies a `SupabaseApiError` into either a
* typed network error or a typed unexpected-status error. Shared by command
Expand Down Expand Up @@ -128,11 +166,18 @@ export function mapHttpError<N, S>(opts: {
Effect.orElseSucceed(() => cause.reason.description ?? ""),
);
const body = sanitizeErrorBody(rawBody);
const entitlement =
status >= 400 && status < 500
? parsePlanGateEnvelope(
yield* cause.response.json.pipe(Effect.orElseSucceed(() => undefined)),
)
: undefined;
return yield* Effect.fail(
new opts.statusError({
status,
body,
message: opts.statusMessage(status, body),
...(entitlement === undefined ? {} : { entitlement }),
}),
);
}
Expand Down
94 changes: 94 additions & 0 deletions apps/cli/src/command-internal/http-errors.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { describe, expect, it } from "@effect/vitest";
import { SupabaseApiInputError } from "@supabase/api/effect";
import { Data, Effect } from "effect";
import * as HttpBody from "effect/http/HttpBody";
import * as HttpClientError from "effect/http/HttpClientError";
import * as HttpClientRequest from "effect/http/HttpClientRequest";
import * as HttpClientResponse from "effect/http/HttpClientResponse";
import { classifyCliErrorActionability } from "../shared/telemetry/error-actionability.ts";

import { sanitizeInlineName, mapHttpError, unexpectedStatusMessage } from "./http-errors.ts";
Expand Down Expand Up @@ -60,6 +63,97 @@ describe("mapHttpError", () => {
);
});

const UPGRADE_URL = "https://supabase.com/dashboard/org/acme/billing";

function statusFailure(status: number, body: string): HttpClientError.HttpClientError {
const request = HttpClientRequest.get("https://api.supabase.com/v1/projects/test/backups");
const response = HttpClientResponse.fromWeb(request, new Response(body, { status }));
return new HttpClientError.HttpClientError({
reason: new HttpClientError.StatusCodeError({ request, response }),
});
}

function planGateBody(error: Record<string, unknown>, message = "Upgrade required"): string {
return JSON.stringify({ message, error: { code: "entitlement_required", ...error } });
}

describe("mapHttpError plan-gate entitlement", () => {
it.effect("attaches the entitlement from a gated body longer than the embedded body cap", () =>
Effect.gen(function* () {
const body = planGateBody({ feature: "backups", upgrade_url: UPGRADE_URL }, "x".repeat(2000));

const error = yield* mapError(statusFailure(402, body)).pipe(Effect.flip);

expect(error).toMatchObject({
_tag: "TestStatusError",
status: 402,
entitlement: { feature: "backups", upgrade_url: UPGRADE_URL },
});
}),
);

it.effect.each(["", "<html>Bad Gateway</html>", '{"message":"Not Found"}'])(
"keeps the plain status error for a non-envelope 4xx body %j",
(body) =>
Effect.gen(function* () {
const error = yield* mapError(statusFailure(403, body)).pipe(Effect.flip);

expect(error).toMatchObject({ _tag: "TestStatusError", status: 403, body });
expect(error).not.toHaveProperty("entitlement");
}),
);

it.effect("ignores a plan-gate body on a 5xx", () =>
Effect.gen(function* () {
const body = planGateBody({ feature: "backups", upgrade_url: UPGRADE_URL });

const error = yield* mapError(statusFailure(503, body)).pipe(Effect.flip);

expect(error).not.toHaveProperty("entitlement");
}),
);

it.effect.each([
{ case: "a non-web upgrade_url", feature: "backups", upgradeUrl: "javascript:alert(1)" },
{
case: "an upgrade_url carrying credentials",
feature: "backups",
upgradeUrl: "https://supabase.com@evil.example/billing",
},
{
case: "a feature carrying a bidi override",
feature: `backups${String.fromCodePoint(0x202e)}`,
upgradeUrl: UPGRADE_URL,
},
])("drops the entitlement for $case", ({ feature, upgradeUrl }) =>
Effect.gen(function* () {
const body = planGateBody({ feature, upgrade_url: upgradeUrl });

const error = yield* mapError(statusFailure(402, body)).pipe(Effect.flip);

expect(error).toMatchObject({ _tag: "TestStatusError", status: 402 });
expect(error).not.toHaveProperty("entitlement");
}),
);

it.effect("percent-encodes control and bidi characters in the upgrade_url", () =>
Effect.gen(function* () {
const rlo = String.fromCodePoint(0x202e);
const csi = String.fromCodePoint(0x9b);
const body = planGateBody({
feature: "backups",
upgrade_url: `${UPGRADE_URL}?ref=${rlo}${csi}`,
});

const error = yield* mapError(statusFailure(402, body)).pipe(Effect.flip);

expect(error).toMatchObject({
entitlement: { feature: "backups", upgrade_url: `${UPGRADE_URL}?ref=%E2%80%AE%C2%9B` },
});
}),
);
});

describe("unexpectedStatusMessage", () => {
it("shapes the generic unexpected-status message", () => {
expect(unexpectedStatusMessage(500, '{"message":"boom"}')).toBe(
Expand Down
32 changes: 3 additions & 29 deletions apps/cli/src/command-internal/upgrade-suggest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
PropOrgSlug,
} from "../shared/telemetry/event-catalog.ts";
import { Output } from "../shared/output/output.service.ts";
import { parsePlanGateEnvelope } from "./http-errors.ts";
import { billingUrl } from "./profile.ts";
import { block } from "./command-permissions/model.ts";

Expand All @@ -33,33 +34,6 @@ export const upgradeSuggestion = block("upgrade-suggest", [
},
]);

function readString(obj: unknown, key: string): string {
if (typeof obj === "object" && obj !== null && key in obj) {
const value = (obj as Record<string, unknown>)[key];
return typeof value === "string" ? value : "";
}
return "";
}

interface PlanGateEnvelope {
readonly feature: string;
readonly upgradeUrl: string;
}

// Hand-validated: the packages/api codegen emits no non-2xx schemas.
function parsePlanGateEnvelope(body: unknown): PlanGateEnvelope | undefined {
if (typeof body !== "object" || body === null) return undefined;
const error = (body as { readonly error?: unknown }).error;
if (typeof error !== "object" || error === null) return undefined;
const code = readString(error, "code");
const feature = readString(error, "feature");
const upgradeUrl = readString(error, "upgrade_url");
if (code !== "entitlement_required" || feature === "" || upgradeUrl === "") {
return undefined;
}
return { feature, upgradeUrl };
}

function orgSlugFromUpgradeUrl(upgradeUrl: string): string {
const match = /\/org\/([^/]+)/.exec(upgradeUrl);
return match?.[1] ?? "";
Expand Down Expand Up @@ -138,9 +112,9 @@ export const suggestUpgrade = Effect.fn("UpgradeSuggest.check")(function* (opts:
const envelope = Option.isSome(body) ? parsePlanGateEnvelope(body.value) : undefined;
if (envelope !== undefined) {
gate = {
billingUrl: envelope.upgradeUrl,
billingUrl: envelope.upgrade_url,
feature: envelope.feature,
orgSlug: orgSlugFromUpgradeUrl(envelope.upgradeUrl),
orgSlug: orgSlugFromUpgradeUrl(envelope.upgrade_url),
};
}
}
Expand Down
3 changes: 3 additions & 0 deletions apps/cli/src/commands/branches/branches.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
ErrorActionabilityId,
statusCodeActionability,
} from "../../shared/telemetry/error-actionability.ts";
import type { PlanGateEntitlement } from "../../shared/output/normalize-error.ts";

export class BranchesListNetworkError extends Data.TaggedError("BranchesListNetworkError")<{
readonly message: string;
Expand Down Expand Up @@ -48,6 +49,7 @@ export class BranchesCreateUnexpectedStatusError extends Data.TaggedError(
readonly body: string;
readonly message: string;
readonly upgradeSuggested?: boolean;
readonly entitlement?: PlanGateEntitlement;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
// A non-gated 409 means the branch name already exists (user input, not a raw API status);
Expand Down Expand Up @@ -184,6 +186,7 @@ export class BranchesUpdateUnexpectedStatusError extends Data.TaggedError(
readonly body: string;
readonly message: string;
readonly upgradeSuggested?: boolean;
readonly entitlement?: PlanGateEntitlement;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return statusCodeActionability(this.status, {
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/commands/branches/create/create.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@ export const branchesCreate = Effect.fn("branches.create")(function* (flags: Bra
body: mapped.body,
message: mapped.message,
upgradeSuggested,
entitlement: mapped.entitlement,
}),
),
),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { branchesCreateCommand, type BranchesCreateFlags } from "./create.comman
import { branchesCreate } from "./create.handler.ts";
import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts";
import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts";
import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts";

type CreatedBranch = typeof V1CreateABranchOutput.Type;

Expand Down Expand Up @@ -487,6 +488,32 @@ describe("branches create integration", () => {
}).pipe(Effect.provide(layer));
});

it.effect("carries the envelope's entitlement on the JSON-mode failure", () => {
const upgradeUrl = "https://supabase.com/dashboard/org/env-org/billing";
const { layer, out } = setup({
format: "json",
status: 402,
response: {
message: "Branching is supported only on the Pro plan or above",
error: {
code: "entitlement_required",
feature: "branching_limit",
upgrade_url: upgradeUrl,
},
},
});
return Effect.gen(function* () {
yield* branchesCreate({ ...baseFlags, name: Option.some("feat-x") }).pipe(
withJsonErrorHandling,
);
expect(out.failures).toHaveLength(1);
expect(out.failures[0]).toMatchObject({
code: "BranchesCreateUnexpectedStatusError",
entitlement: { feature: "branching_limit", upgrade_url: upgradeUrl },
});
}).pipe(Effect.provide(layer));
});

it.effect("does NOT fire upgrade suggested on 500 (Go skips 5xx)", () => {
const { layer, analytics } = setup({ status: 500 });
return Effect.gen(function* () {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type V1ListAllProjectsOutput } from "@supabase/api/effect";
import { describe, expect, it } from "@effect/vitest";
import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect";

import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts";
import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts";
import {
VALID_REF,
Expand Down Expand Up @@ -156,6 +157,36 @@ describe("branches unpause integration", () => {
}).pipe(Effect.provide(layer));
});

it.effect("carries the plan-gate entitlement on a JSON-mode gated restore", () => {
const upgradeUrl = "https://supabase.com/dashboard/org/acme/billing";
const out = mockOutput({ format: "json" });
const api = mockCommandPlatformApi({
handler: (request) =>
Effect.sync(() =>
jsonResponse(request, 402, {
message: "Restoring this project requires a paid plan",
error: {
code: "entitlement_required",
feature: "project_restore_size",
upgrade_url: upgradeUrl,
},
}),
),
});
const cliSettings = mockCommandSettings({ workdir: tempRoot.current });
const layer = buildTestRuntime({ out, api, cliSettings });
return Effect.gen(function* () {
yield* branchesUnpause({ ...baseFlags, name: Option.some(BRANCH_REF) }).pipe(
withJsonErrorHandling,
);
expect(out.failures).toHaveLength(1);
expect(out.failures[0]).toMatchObject({
code: "BranchesUnpauseUnexpectedStatusError",
entitlement: { feature: "project_restore_size", upgrade_url: upgradeUrl },
});
}).pipe(Effect.provide(layer));
});

it.effect(
"unpauses the branch chosen from the pickers and fills the real linked-project cache",
() => {
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/commands/branches/update/update.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export const branchesUpdate = Effect.fn("branches.update")(function* (flags: Bra
body: mapped.body,
message: mapped.message,
upgradeSuggested,
entitlement: mapped.entitlement,
}),
),
),
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/commands/sso/add/add.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ const handleAddError = <R>(
body: mapped.body,
message: mapped.message,
upgradeSuggested,
entitlement: mapped.entitlement,
});
}),
),
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/commands/sso/list/list.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const handleListError = (ref: string, cause: SupabaseApiError) =>
body: mapped.body,
message: mapped.message,
upgradeSuggested,
entitlement: mapped.entitlement,
});
}),
),
Expand Down
1 change: 1 addition & 0 deletions apps/cli/src/commands/sso/remove/remove.handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ const handleRemoveError = (ref: string, providerId: string, cause: SupabaseApiEr
body: mapped.body,
message: mapped.message,
upgradeSuggested,
entitlement: mapped.entitlement,
});
}),
),
Expand Down
Loading
Loading