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
9 changes: 6 additions & 3 deletions apps/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -229,9 +229,12 @@ 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.
Plan-gated denials carry an optional `entitlement: { feature, upgrade_url }` on the error.
`mapHttpError` attaches it from the Management API's `entitlement_required` body. The json and
stream-json error object carries it, the text layer's `fail` prints the upgrade hint from it, and
`withCommandTelemetry` fires `cli_upgrade_suggested` from it, so a handler that rebuilds a mapped
status error must forward it. `suggestUpgrade` prints and fires only on its entitlements-lookup
fallback.

## Targets and hosted consent

Expand Down
125 changes: 41 additions & 84 deletions apps/cli/src/command-internal/upgrade-suggest.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,3 @@
import { styleText } from "node:util";

import type { SupabaseApiError } from "@supabase/api/effect";
import { Effect, Option, type Redacted } from "effect";
import * as HttpClientError from "effect/http/HttpClientError";
import type * as HttpClientResponse from "effect/http/HttpClientResponse";
Expand All @@ -13,6 +10,7 @@ import {
PropFeatureKey,
PropOrgSlug,
} from "../shared/telemetry/event-catalog.ts";
import { upgradeHint } from "../shared/output/normalize-error.ts";
import { Output } from "../shared/output/output.service.ts";
import { parsePlanGateEnvelope } from "./http-errors.ts";
import { billingUrl } from "./profile.ts";
Expand All @@ -34,32 +32,10 @@ export const upgradeSuggestion = block("upgrade-suggest", [
},
]);

function orgSlugFromUpgradeUrl(upgradeUrl: string): string {
const match = /\/org\/([^/]+)/.exec(upgradeUrl);
return match?.[1] ?? "";
}

export function gateResponse(cause: unknown): HttpClientResponse.HttpClientResponse | undefined {
return HttpClientError.isHttpClientError(cause) ? cause.response : undefined;
}

export const gateMapError =
<E, R2>(
opts: { readonly projectRef: string; readonly featureKey?: string },
mapError: (cause: SupabaseApiError, upgradeSuggested: boolean) => Effect.Effect<never, E, R2>,
) =>
(cause: SupabaseApiError) =>
Effect.gen(function* () {
const response = gateResponse(cause);
const upgradeSuggested = yield* suggestUpgrade({
projectRef: opts.projectRef,
featureKey: opts.featureKey,
statusCode: response?.status ?? 0,
response,
});
return yield* mapError(cause, upgradeSuggested);
});

/**
* Suggests an upgrade when a request fails with a plan-gate error. Never
* fails the caller.
Expand Down Expand Up @@ -91,8 +67,6 @@ export const suggestUpgrade = Effect.fn("UpgradeSuggest.check")(function* (opts:
* an unauthenticated request), `undefined` uses the client's own token.
*/
readonly accessToken?: Option.Option<Redacted.Redacted<string>>;
/** Set false for call sites that must not fire `EventUpgradeSuggested` (vanity check-availability). */
readonly trackAnalytics?: boolean;
}) {
if (opts.statusCode < 400 || opts.statusCode >= 500) {
return false;
Expand All @@ -103,81 +77,64 @@ export const suggestUpgrade = Effect.fn("UpgradeSuggest.check")(function* (opts:
const cliSettings = yield* CommandSettings;
yield* Effect.annotateCurrentSpan("upgrade.gated", false);

let gate:
| { readonly billingUrl: string; readonly feature: string; readonly orgSlug: string }
| undefined;

if (opts.response !== undefined) {
const body = yield* opts.response.json.pipe(Effect.option);
const envelope = Option.isSome(body) ? parsePlanGateEnvelope(body.value) : undefined;
if (envelope !== undefined) {
gate = {
billingUrl: envelope.upgrade_url,
feature: envelope.feature,
orgSlug: orgSlugFromUpgradeUrl(envelope.upgrade_url),
};
// The error carries `entitlement`, so the command wrapper prints the hint and fires the event.
yield* Effect.annotateCurrentSpan("upgrade.gated", true);
return true;
}
}

if (gate === undefined) {
if (opts.featureKey === undefined || opts.featureKey === "") {
return false;
}

if (opts.accessToken !== undefined && Option.isNone(opts.accessToken)) {
return false;
}
if (opts.featureKey === undefined || opts.featureKey === "") {
return false;
}

const api = yield* CommandPlatformApi;
const requestOptions = {
baseUrl: opts.apiUrl,
accessToken: opts.accessToken === undefined ? undefined : opts.accessToken.value,
};
const project = yield* api.v1
.getProject({ ref: opts.projectRef }, requestOptions)
.pipe(Effect.option);
if (Option.isNone(project)) {
return false;
}
const orgSlug = project.value.organization_slug;
if (orgSlug.length === 0) {
return false;
}
if (opts.accessToken !== undefined && Option.isNone(opts.accessToken)) {
return false;
}

const entitlements = yield* api.v1
.getOrganizationEntitlements({ slug: orgSlug }, requestOptions)
.pipe(Effect.option);
if (Option.isNone(entitlements)) {
return false;
}
const api = yield* CommandPlatformApi;
const requestOptions = {
baseUrl: opts.apiUrl,
accessToken: opts.accessToken === undefined ? undefined : opts.accessToken.value,
};
const project = yield* api.v1
.getProject({ ref: opts.projectRef }, requestOptions)
.pipe(Effect.option);
if (Option.isNone(project)) {
return false;
}
const orgSlug = project.value.organization_slug;
if (orgSlug.length === 0) {
return false;
}

const gated = entitlements.value.entitlements.some(
(entry) => entry.feature.key === opts.featureKey && entry.hasAccess === false,
);
if (!gated) {
return false;
}
const entitlements = yield* api.v1
.getOrganizationEntitlements({ slug: orgSlug }, requestOptions)
.pipe(Effect.option);
if (Option.isNone(entitlements)) {
return false;
}

gate = {
billingUrl: billingUrl(cliSettings.profile, orgSlug),
feature: opts.featureKey,
orgSlug,
};
const gated = entitlements.value.entitlements.some(
(entry) => entry.feature.key === opts.featureKey && entry.hasAccess === false,
);
if (!gated) {
return false;
}

yield* Effect.annotateCurrentSpan("upgrade.gated", true);
const suggestion = `Your organization does not have access to this feature. Upgrade your plan: ${styleText("bold", gate.billingUrl)}`;

if (output.format === "text") {
yield* output.raw(suggestion + "\n", "stderr");
yield* output.raw(upgradeHint(billingUrl(cliSettings.profile, orgSlug)) + "\n", "stderr");
}

if (opts.trackAnalytics !== false) {
yield* analytics.capture(EventUpgradeSuggested, {
[PropFeatureKey]: gate.feature,
[PropOrgSlug]: gate.orgSlug,
});
}
yield* analytics.capture(EventUpgradeSuggested, {
[PropFeatureKey]: opts.featureKey,
[PropOrgSlug]: orgSlug,
});

return true;
});
91 changes: 22 additions & 69 deletions apps/cli/src/command-internal/upgrade-suggest.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -258,20 +258,6 @@ describe("suggestUpgrade", () => {
}).pipe(Effect.provide(layer));
});

it.live("skips cli_upgrade_suggested analytics when trackAnalytics=false", () => {
const { layer, analytics, out } = setup();
return Effect.gen(function* () {
yield* suggestUpgrade({
projectRef: VALID_REF,
featureKey: "branching_limit",
statusCode: 402,
trackAnalytics: false,
});
expect(analytics.captured).toHaveLength(0);
expect(out.stderrText).toContain("Upgrade your plan:");
}).pipe(Effect.provide(layer));
});

const ENVELOPE_BODY = {
message: "Branching is supported only on the Pro plan or above",
error: {
Expand All @@ -286,31 +272,29 @@ describe("suggestUpgrade", () => {
return jsonResponse(request, status, body);
}

it.live("envelope path suggests with zero API calls and envelope-derived telemetry", () => {
const { layer, out, analytics, api } = setup();
return Effect.gen(function* () {
yield* suggestUpgrade({
projectRef: VALID_REF,
featureKey: "branching_limit",
statusCode: 402,
response: gateResponse(402, ENVELOPE_BODY),
});
expect(out.stderrText).toContain("Upgrade your plan:");
expect(out.stderrText).toContain("/org/env-org/billing");
expect(analytics.captured).toEqual([
{
event: "cli_upgrade_suggested",
properties: { feature_key: "branching_persistent", org_slug: "env-org" },
},
]);
expect(api.requests).toHaveLength(0);
}).pipe(Effect.provide(layer));
});
it.live(
"envelope path confirms the gate with zero API calls and leaves the hint and event to the command wrapper",
() => {
const { layer, out, analytics, api } = setup();
return Effect.gen(function* () {
const suggested = yield* suggestUpgrade({
projectRef: VALID_REF,
featureKey: "branching_limit",
statusCode: 402,
response: gateResponse(402, ENVELOPE_BODY),
});
expect(suggested).toBe(true);
expect(out.stderrText).toBe("");
expect(analytics.captured).toHaveLength(0);
expect(api.requests).toHaveLength(0);
}).pipe(Effect.provide(layer));
},
);

it.live("envelope path works without a featureKey (envelope-only sites)", () => {
const { layer, out, analytics, api } = setup();
return Effect.gen(function* () {
yield* suggestUpgrade({
const suggested = yield* suggestUpgrade({
projectRef: VALID_REF,
statusCode: 400,
response: gateResponse(400, {
Expand All @@ -322,14 +306,10 @@ describe("suggestUpgrade", () => {
},
}),
});
expect(suggested).toBe(true);
expect(api.requests).toHaveLength(0);
expect(out.stderrText).toContain("Upgrade your plan:");
expect(analytics.captured).toEqual([
{
event: "cli_upgrade_suggested",
properties: { feature_key: "custom_domain", org_slug: "env-org" },
},
]);
expect(out.stderrText).toBe("");
expect(analytics.captured).toHaveLength(0);
}).pipe(Effect.provide(layer));
});

Expand Down Expand Up @@ -436,31 +416,4 @@ describe("suggestUpgrade", () => {
expect(out.stderrText).toContain("/org/test-org/billing");
}).pipe(Effect.provide(layer));
});

it.live("envelope path respects json output mode (no stderr, telemetry fires)", () => {
const { layer, out, analytics } = setup({ format: "json" });
return Effect.gen(function* () {
yield* suggestUpgrade({
projectRef: VALID_REF,
statusCode: 402,
response: gateResponse(402, ENVELOPE_BODY),
});
expect(out.stderrText).toBe("");
expect(analytics.captured).toHaveLength(1);
}).pipe(Effect.provide(layer));
});

it.live("envelope path respects trackAnalytics=false", () => {
const { layer, out, analytics } = setup();
return Effect.gen(function* () {
yield* suggestUpgrade({
projectRef: VALID_REF,
statusCode: 402,
response: gateResponse(402, ENVELOPE_BODY),
trackAnalytics: false,
});
expect(analytics.captured).toHaveLength(0);
expect(out.stderrText).toContain("Upgrade your plan:");
}).pipe(Effect.provide(layer));
});
});
13 changes: 3 additions & 10 deletions apps/cli/src/commands/branches/branches.errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,19 +48,14 @@ export class BranchesCreateUnexpectedStatusError extends Data.TaggedError(
readonly status: number;
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);
// the gate check stays ahead so a plan-limited 409 still classifies as plan_limit.
if (this.upgradeSuggested !== true && this.status === 409) {
// A 409 means the branch name already exists (user input, not a raw API status).
if (this.status === 409) {
return { ...actionability.invalidInput, fingerprint_suffix: "conflict" };
}
return statusCodeActionability(this.status, {
upgradeSuggested: this.upgradeSuggested,
notFoundIsInvalidInput: true,
});
return statusCodeActionability(this.status, { notFoundIsInvalidInput: true });
}
}

Expand Down Expand Up @@ -185,12 +180,10 @@ export class BranchesUpdateUnexpectedStatusError extends Data.TaggedError(
readonly status: number;
readonly body: string;
readonly message: string;
readonly upgradeSuggested?: boolean;
readonly entitlement?: PlanGateEntitlement;
}> {
get [ErrorActionabilityId](): CliErrorActionabilityDeclaration {
return statusCodeActionability(this.status, {
upgradeSuggested: this.upgradeSuggested,
notFoundIsInvalidInput: true,
});
}
Expand Down
10 changes: 7 additions & 3 deletions apps/cli/src/commands/branches/branches.errors.unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import {
} from "./branches.errors.ts";

const body = { body: "not found", message: "boom" };
const gatedEntitlement = {
feature: "branching_limit",
upgrade_url: "https://supabase.com/dashboard/org/env-org/billing",
};

describe("branch operation 404s classify as invalid input", () => {
it("pause 404 → invalid input", () => {
Expand Down Expand Up @@ -67,7 +71,7 @@ describe("gated branch operation 404s", () => {
new BranchesUpdateUnexpectedStatusError({
status: 404,
...body,
upgradeSuggested: true,
entitlement: gatedEntitlement,
}),
);
expect(result.error_category).toBe("plan_limit");
Expand All @@ -79,7 +83,7 @@ describe("gated branch operation 404s", () => {
new BranchesCreateUnexpectedStatusError({
status: 404,
...body,
upgradeSuggested: true,
entitlement: gatedEntitlement,
}),
);
expect(result.error_category).toBe("plan_limit");
Expand All @@ -102,7 +106,7 @@ describe("branches create 409 = duplicate branch name", () => {
new BranchesCreateUnexpectedStatusError({
status: 409,
...body,
upgradeSuggested: true,
entitlement: gatedEntitlement,
}),
);
expect(result.error_category).toBe("plan_limit");
Expand Down
Loading
Loading