diff --git a/.github/MAINTAINERS.md b/.github/MAINTAINERS.md index c0eba167bb..25c044e909 100644 --- a/.github/MAINTAINERS.md +++ b/.github/MAINTAINERS.md @@ -83,6 +83,32 @@ the preview. This is independent of `run-ci` and `run-live-e2e-ci`. The `run-preview-packages` label must exist as a repository label; create it from **Issues → Labels** if it is missing. +## `run-flaky-check`: repeat the suites to find flaky tests + +[`flaky-check.yml`](./workflows/flaky-check.yml) runs one commit on many fresh +runners with retries off. A test that passes in some runs and fails in others is +reported as flaky; one that fails in every run is reported separately. + +| Trigger | Commit | Suites | +| ------------------------ | --------------- | --------------------------------------------- | +| Nightly, 03:17 UTC | `develop` | unit, integration ×10 | +| Weekly, Sunday 03:47 UTC | `develop` | CLI e2e, stack e2e ×3 | +| `run-flaky-check` label | PR merge commit | unit, integration ×10, plus `focused` | +| Manual dispatch | any ref | chosen suites, runs, repeats, and file filter | + +`focused` runs the tests affected by the PR (`vitest --changed` against the merge +base) five times per job, each in a fresh Vitest process, to show a flaky-test fix +holds; a changed `package.json` or Vitest config selects every test. Dispatch with +`suites=focused` and a `filter` to target specific files. + +The label is removed when the run starts, so re-add it to run again (by hand on +fork PRs, which also get no comment). The report goes to the job summary, the +`flaky-check-report` artifact, and one PR comment, and fails the run when any test +failed or any run produced no results. + +The `run-flaky-check` label must exist as a repository label; create it from +**Issues → Labels** if it is missing. + ## Live e2e coverage and stable releases [`Live E2E`](./workflows/live-e2e.yml) exercises managed staging after every push diff --git a/.github/scripts/flaky-check/cli.ts b/.github/scripts/flaky-check/cli.ts new file mode 100644 index 0000000000..979fa17b77 --- /dev/null +++ b/.github/scripts/flaky-check/cli.ts @@ -0,0 +1,291 @@ +import { + appendFileSync, + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; +import { basename, dirname, join } from "node:path"; +import type { JsonTestResults } from "vitest/node"; +import { + CheckError, + collectedName, + commandsFor, + type Entry, + plan, + RESULTS_DIR, + type ReportSource, +} from "./plan.ts"; +import { + aggregate, + COMMENT_MARKER, + commentBody, + isClean, + parseVitestJson, + renderMarkdown, + type RunMeta, + type RunResult, +} from "./report.ts"; + +function env(name: string): string { + return process.env[name] ?? ""; +} + +function setOutputs(values: Record): void { + const lines = Object.entries(values).map(([key, value]) => `${key}=${value}\n`); + appendFileSync(env("GITHUB_OUTPUT") || "/dev/stdout", lines.join("")); +} + +function capture(argv: string[]): string { + const result = Bun.spawnSync(argv, { stderr: "inherit" }); + if (result.exitCode !== 0) { + throw new CheckError(`${argv.join(" ")} exited ${result.exitCode}`); + } + return result.stdout.toString().trim(); +} + +function words(value: string): string[] { + return value.split(/\s+/).filter((word) => word !== ""); +} + +function planCommand(): void { + const { entries, base, filter } = plan({ + event: env("EVENT_NAME"), + schedule: env("SCHEDULE"), + baseRef: env("PR_BASE_REF"), + suites: env("INPUT_SUITES"), + runs: env("INPUT_RUNS"), + repeats: env("INPUT_REPEATS"), + filter: env("INPUT_FILTER"), + }); + // github.sha is already the commit to test (a PR's merge commit, or the branch head), unless a + // dispatch names another ref. + const ref = env("INPUT_REF"); + const sha = + ref === "" + ? env("EVENT_SHA") + : capture([ + "gh", + "api", + `repos/${env("GITHUB_REPOSITORY")}/commits/${encodeURIComponent(ref)}`, + "--jq", + ".sha", + ]); + setOutputs({ sha, base, filter, matrix: JSON.stringify({ include: entries }) }); +} + +type TurboTask = { task: string; package: string; directory: string; command: string }; + +/** Collected report names an invocation should produce, so a report that never appears is flagged. */ +function expectedReports(source: ReportSource, turboCache: Map): string[] { + if ("dir" in source) { + return [collectedName(source.dir, source.report)]; + } + const key = source.turbo.join(" "); + let tasks = turboCache.get(key); + if (tasks === undefined) { + const dryRun: { tasks: TurboTask[] } = JSON.parse( + capture(["pnpm", "exec", "turbo", "run", ...source.turbo, "--dry=json"]), + ); + tasks = dryRun.tasks.filter( + (task) => task.task === source.turbo[0] && task.command !== "", + ); + turboCache.set(key, tasks); + } + return tasks.map((task) => { + // Turbo forwards arguments to the last command of a chained script only. + if (task.command.includes("&&")) { + throw new CheckError( + `${task.package} chains commands in ${task.task}, so only its last one would write a report`, + ); + } + return collectedName(task.directory, source.report); + }); +} + +async function runCommand(): Promise { + const entry: Entry = JSON.parse(env("MATRIX")); + const filters = words(env("FILTER")); + const extra = + entry.suite === "focused" && filters.length === 0 + ? ["--changed", capture(["git", "merge-base", "HEAD", `origin/${env("BASE_REF")}`])] + : filters; + if (entry.suite === "e2e") { + process.env.SUPABASE_GO_BINARY = join(process.cwd(), "apps/cli-go/supabase-go"); + } + if (entry.suite === "e2e" && entry.target === "cli") { + // Matches test.yml, so concurrency-sensitive flakes show up here too. + process.env.SUPABASE_E2E_WORKERS = "4"; + } + let exitCode = 0; + const expected: string[] = []; + const failedInvocations: RunMeta["failedInvocations"] = []; + const turboCache = new Map(); + for (let iteration = 1; iteration <= entry.executions; iteration += 1) { + for (const { argv, reports } of commandsFor(entry, iteration, extra)) { + const names = expectedReports(reports, turboCache); + expected.push(...names); + console.log(`$ ${argv.join(" ")}`); + const code = await Bun.spawn(argv, { stdout: "inherit", stderr: "inherit" }).exited; + if (code !== 0) { + exitCode = code; + failedInvocations.push({ reports: names, exitCode: code }); + } + } + } + setOutputs({ + "exit-code": String(exitCode), + "expected-reports": JSON.stringify(expected), + "failed-invocations": JSON.stringify(failedInvocations), + }); + return exitCode; +} + +function collectCommand(): void { + const entry: Entry = JSON.parse(env("MATRIX")); + const out = join(env("RUNNER_TEMP"), "flaky-results", entry.name); + mkdirSync(out, { recursive: true }); + for (const report of new Bun.Glob(`{apps,packages}/*/${RESULTS_DIR}/*.json`).scanSync({ + dot: true, + })) { + copyFileSync( + report, + join(out, collectedName(dirname(dirname(report)), basename(report, ".json"))), + ); + } + const meta: RunMeta = { + name: entry.name, + suite: entry.suite, + run: entry.run, + executions: entry.executions, + exitCode: env("EXIT_CODE") === "" ? null : Number(env("EXIT_CODE")), + root: process.cwd(), + expectedReports: JSON.parse(env("EXPECTED_REPORTS") || "[]"), + failedInvocations: JSON.parse(env("FAILED_INVOCATIONS") || "[]"), + }; + writeFileSync(join(out, "meta.json"), `${JSON.stringify(meta, null, 2)}\n`); + setOutputs({ dir: out }); +} + +/** A missing, truncated, or foreign report reads as absent, which the aggregation flags. */ +function readCases(path: string, root: string): ReturnType | undefined { + try { + const results: JsonTestResults = JSON.parse(readFileSync(path, "utf8")); + return parseVitestJson(results, root); + } catch { + return undefined; + } +} + +function readResults(dir: string): RunResult[] { + if (!existsSync(dir)) { + return []; + } + return readdirSync(dir) + .filter((name) => existsSync(join(dir, name, "meta.json"))) + .map((name) => { + const runDir = join(dir, name); + const meta: RunMeta = JSON.parse(readFileSync(join(runDir, "meta.json"), "utf8")); + const reports = readdirSync(runDir) + .filter((file) => file.endsWith(".json") && file !== "meta.json") + .flatMap((file) => { + const cases = readCases(join(runDir, file), meta.root); + const iteration = Number(/\.(\d+)\.json$/.exec(file)?.[1] ?? "1"); + return cases === undefined ? [] : [{ name: file, iteration, cases }]; + }); + return { meta, reports }; + }); +} + +function reportCommand(): void { + const [resultsDir = "results", outDir = "flaky-report"] = process.argv.slice(3); + const matrix: { include: Entry[] } = JSON.parse(env("MATRIX") || '{"include":[]}'); + const report = aggregate( + readResults(resultsDir), + matrix.include.map((entry) => entry.name), + env("FILTER") === "" ? ["focused"] : [], + ); + const markdown = renderMarkdown(report, { + sha: env("SHA"), + runUrl: `${env("GITHUB_SERVER_URL")}/${env("GITHUB_REPOSITORY")}/actions/runs/${env("GITHUB_RUN_ID")}`, + }); + mkdirSync(outDir, { recursive: true }); + writeFileSync(join(outDir, "report.json"), `${JSON.stringify(report, null, 2)}\n`); + writeFileSync(join(outDir, "report.md"), markdown); + appendFileSync(env("GITHUB_STEP_SUMMARY") || "/dev/stdout", markdown); + if (!isClean(report)) { + console.log( + "::error ::Flaky, failing, or missing runs found; see the job summary or the flaky-check-report artifact.", + ); + process.exitCode = 1; + } +} + +/** Creates or updates the one PR comment that starts with the report marker. */ +function commentCommand(): void { + const [reportPath = "flaky-report/report.md"] = process.argv.slice(3); + const issue = `repos/${env("GITHUB_REPOSITORY")}/issues`; + try { + const pages: { id: number; body: string; user: { login: string } }[][] = JSON.parse( + capture(["gh", "api", "--paginate", "--slurp", `${issue}/${env("PR_NUMBER")}/comments`]), + ); + const existing = pages + .flat() + .find( + (comment) => + comment.user.login === "github-actions[bot]" && comment.body.startsWith(COMMENT_MARKER), + ); + const target = + existing === undefined + ? ["POST", `${issue}/${env("PR_NUMBER")}/comments`] + : ["PATCH", `${issue}/comments/${existing.id}`]; + capture([ + "gh", + "api", + "--method", + ...target, + "--raw-field", + `body=${commentBody(readFileSync(reportPath, "utf8"))}`, + ]); + } catch (error) { + // The report stays in the job summary and artifact, so a comment failure only warns. + console.log( + `::warning::Unable to post the flaky-check PR comment: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + +if (import.meta.main) { + const command = process.argv[2]; + try { + switch (command) { + case "plan": + planCommand(); + break; + case "run": + process.exitCode = await runCommand(); + break; + case "collect": + collectCommand(); + break; + case "report": + reportCommand(); + break; + case "comment": + commentCommand(); + break; + default: + throw new CheckError( + `usage: cli.ts plan|run|collect|report|comment, got '${command ?? ""}'`, + ); + } + } catch (error) { + if (!(error instanceof CheckError)) { + throw error; + } + console.log(`::error ::${error.message}`); + process.exitCode = 1; + } +} diff --git a/.github/scripts/flaky-check/fixtures/iteration-1.json b/.github/scripts/flaky-check/fixtures/iteration-1.json new file mode 100644 index 0000000000..7a8ba14f1d --- /dev/null +++ b/.github/scripts/flaky-check/fixtures/iteration-1.json @@ -0,0 +1,60 @@ +{ + "testResults": [ + { + "name": "/repo/packages/api/src/hook.unit.test.ts", + "status": "passed", + "message": "", + "assertionResults": [ + { + "ancestorTitles": ["hook"], + "title": "runs after the hook", + "status": "passed", + "failureMessages": [] + } + ] + }, + { + "name": "/repo/packages/api/src/import-error.unit.test.ts", + "status": "failed", + "message": "import failure", + "assertionResults": [] + }, + { + "name": "/repo/packages/api/src/probe.unit.test.ts", + "status": "failed", + "message": "", + "assertionResults": [ + { + "ancestorTitles": ["sometimes"], + "title": "fails on the second iteration", + "status": "passed", + "failureMessages": [] + }, + { + "ancestorTitles": ["two errors"], + "title": "test and afterEach both fail on the second iteration", + "status": "passed", + "failureMessages": [] + }, + { + "ancestorTitles": ["@supabase/api"], + "title": "twin", + "status": "passed", + "failureMessages": [] + }, + { + "ancestorTitles": ["@supabase/api"], + "title": "twin", + "status": "failed", + "failureMessages": ["Error: second twin"] + }, + { + "ancestorTitles": ["@supabase/api"], + "title": "skipped", + "status": "skipped", + "failureMessages": [] + } + ] + } + ] +} diff --git a/.github/scripts/flaky-check/fixtures/iteration-2.json b/.github/scripts/flaky-check/fixtures/iteration-2.json new file mode 100644 index 0000000000..4f4c94740e --- /dev/null +++ b/.github/scripts/flaky-check/fixtures/iteration-2.json @@ -0,0 +1,60 @@ +{ + "testResults": [ + { + "name": "/repo/packages/api/src/hook.unit.test.ts", + "status": "failed", + "message": "", + "assertionResults": [ + { + "ancestorTitles": ["hook"], + "title": "runs after the hook", + "status": "skipped", + "failureMessages": [] + } + ] + }, + { + "name": "/repo/packages/api/src/import-error.unit.test.ts", + "status": "failed", + "message": "import failure", + "assertionResults": [] + }, + { + "name": "/repo/packages/api/src/probe.unit.test.ts", + "status": "failed", + "message": "", + "assertionResults": [ + { + "ancestorTitles": ["sometimes"], + "title": "fails on the second iteration", + "status": "failed", + "failureMessages": ["Error: second iteration"] + }, + { + "ancestorTitles": ["two errors"], + "title": "test and afterEach both fail on the second iteration", + "status": "failed", + "failureMessages": ["Error: test body", "Error: afterEach too"] + }, + { + "ancestorTitles": ["@supabase/api"], + "title": "twin", + "status": "passed", + "failureMessages": [] + }, + { + "ancestorTitles": ["@supabase/api"], + "title": "twin", + "status": "failed", + "failureMessages": ["Error: second twin"] + }, + { + "ancestorTitles": ["@supabase/api"], + "title": "skipped", + "status": "skipped", + "failureMessages": [] + } + ] + } + ] +} diff --git a/.github/scripts/flaky-check/plan.test.ts b/.github/scripts/flaky-check/plan.test.ts new file mode 100644 index 0000000000..83c752ecb3 --- /dev/null +++ b/.github/scripts/flaky-check/plan.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, test } from "bun:test"; +import { CheckError, commandsFor, type Entry, plan, type PlanInput, WEEKLY_CRON } from "./plan.ts"; + +const input = (overrides: Partial): PlanInput => ({ + event: "workflow_dispatch", + schedule: "", + baseRef: "", + suites: "unit", + runs: "1", + repeats: "4", + filter: "", + ...overrides, +}); + +function entry(name: string): Entry { + const found = plan(input({ suites: "unit,integration,focused,e2e,stack-e2e" })).entries.find( + (e) => e.name === name, + ); + if (found === undefined) { + throw new Error(`no plan entry ${name}`); + } + return found; +} + +function outputOf(argv: string[]): string | undefined { + return argv + .find((arg) => arg.startsWith("--outputFile.json=")) + ?.slice("--outputFile.json=".length); +} + +describe("plan", () => { + test("schedules run unit and integration nightly and the e2e suites on the workflow's weekly cron", async () => { + const workflow = await Bun.file(`${import.meta.dir}/../../workflows/flaky-check.yml`).text(); + expect(JSON.stringify(Bun.YAML.parse(workflow))).toContain(`"cron":"${WEEKLY_CRON}"`); + + const weekly = plan(input({ event: "schedule", schedule: WEEKLY_CRON })).entries; + const nightly = plan(input({ event: "schedule", schedule: "17 3 * * *" })).entries; + + expect(weekly.map((e) => e.name).slice(0, 7)).toEqual([ + "flaky-e2e-cli1-run1", + "flaky-e2e-cli2-run1", + "flaky-e2e-cli3-run1", + "flaky-e2e-cli4-run1", + "flaky-e2e-cli5-run1", + "flaky-e2e-cli-e2e1-run1", + "flaky-e2e-cli-e2e2-run1", + ]); + expect(weekly).toHaveLength(48); + expect(nightly.map((e) => [e.suite, e.runner, e.timeout]).slice(9, 11)).toEqual([ + ["unit", "blacksmith-4vcpu-ubuntu-2404", 30], + ["integration", "blacksmith-8vcpu-ubuntu-2404", 30], + ]); + expect(nightly).toHaveLength(20); + }); + + test("a labeled pull request adds focused jobs that run five executions against the PR base", () => { + const { entries, base } = plan(input({ event: "pull_request", baseRef: "next" })); + + expect(base).toBe("next"); + expect( + entries + .filter((e) => e.suite === "focused") + .map((e) => [e.executions, e.fetchDepth, e.timeout]), + ).toEqual(Array(10).fill([5, 0, 60])); + }); + + test("a dispatch normalizes its inputs and stays within GitHub's matrix limit", () => { + const { entries, filter } = plan( + input({ + suites: " focused , unit ", + runs: " 2", + repeats: "9", + filter: " src/a.ts\nsrc/b.ts ", + }), + ); + + expect(entries.map((e) => [e.name, e.executions])).toEqual([ + ["flaky-unit-run1", 1], + ["flaky-unit-run2", 1], + ["flaky-focused-run1", 10], + ["flaky-focused-run2", 10], + ]); + expect(filter).toBe("src/a.ts src/b.ts"); + expect(() => + plan(input({ suites: "unit,integration,focused,e2e,stack-e2e", runs: "30" })), + ).toThrow("570 jobs exceed GitHub's 256-job matrix limit"); + }); + + test.each([ + [{ suites: "unit,bogus" }, "unknown suite 'bogus'"], + [{ suites: " , " }, "no suites selected"], + [{ runs: "1.5" }, "runs must be a whole number between 1 and 30, got '1.5'"], + ])("rejects %j", (overrides, message) => { + expect(() => plan(input(overrides))).toThrow(CheckError); + expect(() => plan(input(overrides))).toThrow(message); + }); +}); + +describe("commandsFor", () => { + test("each focused iteration runs both @supabase/api projects outside Turbo, with its own reports", () => { + const invocations = commandsFor(entry("flaky-focused-run1"), 3, ["--changed", "abc123"]); + + expect(invocations.map((i) => [i.reports, outputOf(i.argv)])).toEqual([ + [ + { turbo: ["test:unit:run", "--filter=!@supabase/cli-go"], report: "unit.3" }, + ".flaky-results/unit.3.json", + ], + [ + { turbo: ["test:integration:run", "--filter=!@supabase/api"], report: "integration.3" }, + ".flaky-results/integration.3.json", + ], + [ + { dir: "packages/api", report: "integration-node.3" }, + ".flaky-results/integration-node.3.json", + ], + [ + { dir: "packages/api", report: "integration-bun.3" }, + ".flaky-results/integration-bun.3.json", + ], + ]); + expect(invocations[3]?.argv.slice(0, 10)).toEqual([ + "pnpm", + "--dir", + "packages/api", + "exec", + "bun", + "--bun", + "vitest", + "run", + "--project", + "bun-integration", + ]); + for (const { argv } of invocations) { + expect(argv.slice(-2)).toEqual(["--changed", "abc123"]); + } + }); + + test.each([ + [ + "flaky-e2e-cli2-run1", + [{ turbo: ["test:e2e:run", "--only", "--filter=supabase"], report: "e2e.1" }], + "--shard=2/5", + ], + [ + "flaky-e2e-cli-e2e2-run1", + [{ turbo: ["test:e2e:run", "--only", "--filter=@supabase/cli-e2e"], report: "e2e.1" }], + "--shard=2/2", + ], + [ + "flaky-stack-e2e-native-lifecycle-run1", + [ + { dir: "packages/stack", report: "whole-stack.native.lifecycle.1" }, + { dir: "packages/stack", report: "public.1" }, + { dir: "packages/stack", report: "namespace.1" }, + { dir: "packages/stack", report: "port-reservation.1" }, + ], + "src/whole-stack.native.lifecycle.e2e.test.ts", + ], + [ + "flaky-stack-e2e-docker-confinement-run1", + [{ dir: "packages/stack", report: "whole-stack.docker.confinement.1" }], + "--testNamePattern=writes stay confined", + ], + [ + "flaky-stack-e2e-podman-idle-parallel-run1", + [{ dir: "packages/stack", report: "whole-stack.podman.idle-parallel.1" }], + "src/whole-stack.podman.idle-parallel.e2e.test.ts", + ], + ])("%s declares where each report lands", (name, reports, arg) => { + const invocations = commandsFor(entry(name), 1, ["ignored-filter"]); + + expect(invocations.map((i) => i.reports)).toEqual(reports); + expect(invocations.map((i) => outputOf(i.argv))).toEqual( + reports.map((r) => `.flaky-results/${r.report}.json`), + ); + expect(invocations[0]?.argv).toContain(arg); + expect(invocations[0]?.argv).not.toContain("ignored-filter"); + }); +}); diff --git a/.github/scripts/flaky-check/plan.ts b/.github/scripts/flaky-check/plan.ts new file mode 100644 index 0000000000..df37d4e705 --- /dev/null +++ b/.github/scripts/flaky-check/plan.ts @@ -0,0 +1,302 @@ +export const WEEKLY_CRON = "47 3 * * 0"; +/** Directory, relative to each Vitest root, where every run writes its JSON reports. */ +export const RESULTS_DIR = ".flaky-results"; +const SUITES = ["unit", "integration", "focused", "e2e", "stack-e2e"] as const; +type Suite = (typeof SUITES)[number]; +const CLI_E2E_SHARDS = 5; +const CLI_E2E_PACKAGE_SHARDS = 2; +// GitHub rejects a matrix with more jobs than this. +const MAX_JOBS = 256; + +/** One matrix job; the workflow reads its runner, timeout, and checkout depth. */ +export type Entry = { + name: string; + run: number; + /** Executions of the suite in this job, each a separate Vitest process. */ + executions: number; + runner: string; + timeout: number; + fetchDepth: number; +} & ( + | { suite: "unit" | "integration" | "focused" } + | { suite: "e2e"; target: "cli" | "cli-e2e"; shard: number } + | { + suite: "stack-e2e"; + runtime: "native" | "docker" | "podman"; + scenario: "lifecycle" | "confinement" | "idle-parallel"; + } +); + +export type PlanInput = { + event: string; + schedule: string; + baseRef: string; + suites: string; + runs: string; + repeats: string; + filter: string; +}; + +export class CheckError extends Error {} + +function count(value: string, label: string, max: number, min = 0): number { + const parsed = Number(value.trim()); + if (!Number.isInteger(parsed) || parsed < min || parsed > max) { + throw new CheckError( + `${label} must be a whole number between ${min} and ${max}, got '${value}'`, + ); + } + return parsed; +} + +function parseSuites(value: string): Suite[] { + const names = value + .split(",") + .map((name) => name.trim()) + .filter((name) => name !== ""); + const unknown = names.find((name) => !SUITES.some((suite) => suite === name)); + if (unknown !== undefined) { + throw new CheckError(`unknown suite '${unknown}'; choose from ${SUITES.join(",")}`); + } + const suites = SUITES.filter((suite) => names.includes(suite)); + if (suites.length === 0) { + throw new CheckError(`no suites selected; choose from ${SUITES.join(",")}`); + } + return suites; +} + +function range(length: number): number[] { + return Array.from({ length }, (_, index) => index + 1); +} + +function entriesFor(suite: Suite, run: number, repeats: number): Entry[] { + const job = { + run, + executions: 1, + runner: "blacksmith-8vcpu-ubuntu-2404", + timeout: 30, + fetchDepth: 1, + }; + switch (suite) { + case "unit": + return [ + { ...job, suite, name: `flaky-unit-run${run}`, runner: "blacksmith-4vcpu-ubuntu-2404" }, + ]; + case "integration": + return [{ ...job, suite, name: `flaky-integration-run${run}` }]; + case "focused": + // A full history lets focused runs diff against the merge base. + return [ + { + ...job, + suite, + name: `flaky-focused-run${run}`, + executions: repeats + 1, + // GitHub caps a job at 360 minutes. + timeout: Math.min(360, 30 + 6 * (repeats + 1)), + fetchDepth: 0, + }, + ]; + case "e2e": { + const e2e = { ...job, suite, timeout: 45, fetchDepth: 0 }; + return [ + ...range(CLI_E2E_SHARDS).map((shard) => ({ + ...e2e, + target: "cli" as const, + shard, + name: `flaky-e2e-cli${shard}-run${run}`, + })), + ...range(CLI_E2E_PACKAGE_SHARDS).map((shard) => ({ + ...e2e, + target: "cli-e2e" as const, + shard, + name: `flaky-e2e-cli-e2e${shard}-run${run}`, + })), + ]; + } + case "stack-e2e": + return (["native", "docker", "podman"] as const).flatMap((runtime) => + (["lifecycle", "confinement", "idle-parallel"] as const).map((scenario) => ({ + ...job, + suite, + runtime, + scenario, + timeout: 45, + name: `flaky-stack-e2e-${runtime}-${scenario}-run${run}`, + })), + ); + } +} + +/** Matrix jobs for a trigger; `base` is the branch focused runs diff against. */ +export function plan(input: PlanInput): { entries: Entry[]; base: string; filter: string } { + let suites: Suite[] = ["unit", "integration"]; + let runs = 10; + let repeats = 4; + let filter = ""; + let base = "develop"; + if (input.event === "workflow_dispatch") { + suites = parseSuites(input.suites); + runs = count(input.runs, "runs", 30, 1); + repeats = count(input.repeats, "repeats", 100); + filter = input.filter.trim().replace(/\s+/g, " "); + } else if (input.event === "pull_request") { + suites = ["unit", "integration", "focused"]; + base = input.baseRef; + } else if (input.schedule === WEEKLY_CRON) { + suites = ["e2e", "stack-e2e"]; + runs = 3; + } + + const entries = suites.flatMap((suite) => + range(runs).flatMap((run) => entriesFor(suite, run, repeats)), + ); + if (entries.length > MAX_JOBS) { + throw new CheckError( + `${entries.length} jobs exceed GitHub's ${MAX_JOBS}-job matrix limit; lower runs`, + ); + } + return { entries, base, filter }; +} + +/** + * Where an invocation's report lands: one package directory, or every package that runs a Turbo + * task (`turbo` holds the task and its filters, resolved with `turbo run --dry=json`). + */ +export type ReportSource = { report: string } & ({ dir: string } | { turbo: string[] }); + +type Invocation = { argv: string[]; reports: ReportSource }; + +/** The name a collected report gets: `apps/cli` + `unit.1` becomes `apps__cli--unit.1.json`. */ +export function collectedName(dir: string, report: string): string { + return `${dir.replaceAll("/", "__")}--${report}.json`; +} + +function reporter(report: string): string[] { + return [ + "--retry=0", + "--reporter=default", + "--reporter=json", + `--outputFile.json=${RESULTS_DIR}/${report}.json`, + ]; +} + +function turbo(task: string[], report: string, extra: string[]): Invocation { + return { + argv: [ + "pnpm", + "exec", + "turbo", + "run", + ...task, + "--continue=always", + "--", + ...reporter(report), + ...extra, + ], + reports: { turbo: task, report }, + }; +} + +function api(runtime: string[], project: string, report: string, extra: string[]): Invocation { + return { + argv: [ + "pnpm", + "--dir", + "packages/api", + "exec", + ...runtime, + "vitest", + "run", + "--project", + project, + ...reporter(report), + ...extra, + ], + reports: { dir: "packages/api", report }, + }; +} + +function unit(iteration: number, extra: string[]): Invocation[] { + return [ + turbo(["test:unit:run", "--filter=!@supabase/cli-go"], `unit.${iteration}`, [ + "--passWithNoTests", + ...extra, + ]), + ]; +} + +// @supabase/api chains a Node and a Bun Vitest run in one script, and Turbo forwards arguments +// only to the last command, so its two projects run separately with their own reports. +function integration(iteration: number, extra: string[]): Invocation[] { + const args = ["--passWithNoTests", ...extra]; + return [ + turbo(["test:integration:run", "--filter=!@supabase/api"], `integration.${iteration}`, args), + api([], "integration", `integration-node.${iteration}`, args), + api(["bun", "--bun"], "bun-integration", `integration-bun.${iteration}`, args), + ]; +} + +function stack(file: string, report: string, args: string[]): Invocation { + return { + argv: [ + "pnpm", + "--dir", + "packages/stack", + "run", + "test:e2e:run", + `src/${file}.e2e.test.ts`, + ...args, + "--passWithNoTests=false", + ...reporter(report), + ], + reports: { dir: "packages/stack", report }, + }; +} + +/** The invocations of one execution of a job, in order; each runs even when an earlier one fails. */ +export function commandsFor(entry: Entry, iteration: number, extra: string[]): Invocation[] { + switch (entry.suite) { + case "unit": + return unit(iteration, extra); + case "integration": + return integration(iteration, extra); + case "focused": + return [...unit(iteration, extra), ...integration(iteration, extra)]; + case "e2e": + return entry.target === "cli" + ? [ + turbo(["test:e2e:run", "--only", "--filter=supabase"], "e2e.1", [ + `--shard=${entry.shard}/${CLI_E2E_SHARDS}`, + ]), + ] + : [ + turbo(["test:e2e:run", "--only", "--filter=@supabase/cli-e2e"], "e2e.1", [ + `--shard=${entry.shard}/${CLI_E2E_PACKAGE_SHARDS}`, + ]), + ]; + case "stack-e2e": { + // Mirrors test.yml, which gives the confinement scenario of the lifecycle file its own job. + const file = entry.scenario === "idle-parallel" ? "idle-parallel" : "lifecycle"; + const pattern = { + lifecycle: "^(?!.*writes stay confined)", + confinement: "writes stay confined", + "idle-parallel": ".", + }[entry.scenario]; + const scenario = stack( + `whole-stack.${entry.runtime}.${file}`, + `whole-stack.${entry.runtime}.${entry.scenario}.1`, + [`--testNamePattern=${pattern}`, "--sequence.concurrent"], + ); + // test.yml runs these runtime-independent suites once, so one job here does too. + return entry.runtime === "native" && entry.scenario === "lifecycle" + ? [ + scenario, + stack("public", "public.1", []), + stack("namespace/Namespace", "namespace.1", []), + stack("port-reservation", "port-reservation.1", []), + ] + : [scenario]; + } + } +} diff --git a/.github/scripts/flaky-check/report.test.ts b/.github/scripts/flaky-check/report.test.ts new file mode 100644 index 0000000000..4c023f5110 --- /dev/null +++ b/.github/scripts/flaky-check/report.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, test } from "bun:test"; +import type { JsonTestResults } from "vitest/node"; +import { + aggregate, + commentBody, + isClean, + parseVitestJson, + renderMarkdown, + type RunMeta, + type RunResult, +} from "./report.ts"; + +// Real `--reporter=json` output, trimmed to the fields read, of the same three probe files run twice. The second iteration +// fails one test, fails another together with its `afterEach`, and fails a file's `beforeAll`; +// both iterations fail the second of two same-titled tests and a file that throws on import. +const iteration1: JsonTestResults = await Bun.file( + `${import.meta.dir}/fixtures/iteration-1.json`, +).json(); +const iteration2: JsonTestResults = await Bun.file( + `${import.meta.dir}/fixtures/iteration-2.json`, +).json(); +// What Vitest reports for a healthy file: the hook file from the first iteration. +const clean: JsonTestResults = { + ...iteration1, + testResults: iteration1.testResults.filter((file) => file.name.endsWith("hook.unit.test.ts")), +}; +const empty: JsonTestResults = { ...iteration1, testResults: [] }; +const API = "packages__api--unit"; + +function run( + suite: string, + runNumber: number, + reports: Record, + meta: Partial = {}, +): RunResult { + const exitCode = meta.exitCode === undefined ? 0 : meta.exitCode; + return { + meta: { + name: `flaky-${suite}-run${runNumber}`, + suite, + run: runNumber, + executions: 1, + exitCode, + root: "/repo", + expectedReports: Object.keys(reports), + failedInvocations: + exitCode === null || exitCode === 0 ? [] : [{ reports: Object.keys(reports), exitCode }], + ...meta, + }, + reports: Object.entries(reports).map(([name, results]) => ({ + name, + iteration: Number(/\.(\d+)\.json$/.exec(name)?.[1]), + cases: parseVitestJson(results, "/repo"), + })), + }; +} + +function summary(results: RunResult[], expected: string[]) { + const report = aggregate(results, expected); + const rows = (verdicts: typeof report.flaky) => + verdicts.map((v) => [ + v.file.split("/").pop(), + v.name, + `${v.failedIn.length}/${v.executions}`, + v.message, + ]); + return { + flaky: rows(report.flaky), + failing: rows(report.failing), + runProblems: report.runProblems, + }; +} + +describe("aggregate", () => { + test("executions classify tests the same whether they are iterations of one run or separate runs", () => { + const expected = { + flaky: [ + ["hook.unit.test.ts", "(file setup)", "1/2", "failed outside any test (hook or setup)"], + [ + "probe.unit.test.ts", + "sometimes > fails on the second iteration", + "1/2", + "Error: second iteration", + ], + [ + "probe.unit.test.ts", + "two errors > test and afterEach both fail on the second iteration", + "1/2", + "Error: test body", + ], + ], + failing: [ + ["import-error.unit.test.ts", "(file setup)", "2/2", "import failure"], + ["probe.unit.test.ts", "@supabase/api > twin (#2)", "2/2", "Error: second twin"], + ], + runProblems: [], + }; + + const iterations = summary( + [ + run( + "focused", + 1, + { [`${API}.1.json`]: iteration1, [`${API}.2.json`]: iteration2 }, + { executions: 2, exitCode: 1 }, + ), + ], + ["flaky-focused-run1"], + ); + const runs = summary( + [ + run("unit", 1, { [`${API}.1.json`]: iteration1 }, { exitCode: 1 }), + run("unit", 2, { [`${API}.1.json`]: iteration2 }, { exitCode: 1 }), + ], + ["flaky-unit-run1", "flaky-unit-run2"], + ); + + expect(iterations).toEqual(expected); + expect(runs).toEqual(expected); + }); + + test("scenarios sharing a file stay separate, and a failing setup is not an empty run", () => { + const hook = (results: JsonTestResults): JsonTestResults => ({ + ...results, + testResults: results.testResults.filter((file) => file.name.endsWith("hook.unit.test.ts")), + }); + const lifecycle = "packages__stack--whole-stack.docker.lifecycle.1.json"; + const confinement = "packages__stack--whole-stack.docker.confinement.1.json"; + const results = [1, 2].flatMap((n) => [ + run( + "stack-e2e", + n, + { [lifecycle]: hook(iteration2) }, + { name: `lifecycle${n}`, exitCode: 1 }, + ), + run("stack-e2e", n, { [confinement]: hook(iteration1) }, { name: `confinement${n}` }), + ]); + + const report = summary(results, ["lifecycle1", "confinement1", "lifecycle2", "confinement2"]); + + expect(report).toEqual({ + flaky: [], + failing: [ + [ + "hook.unit.test.ts", + "(file setup) [whole-stack.docker.lifecycle]", + "2/2", + "failed outside any test (hook or setup)", + ], + ], + runProblems: [], + }); + }); + + test("a file whose tests were all skipped does not count as a passing setup", () => { + const [hookFile] = clean.testResults; + const failingHook: JsonTestResults = { + ...iteration2, + testResults: iteration2.testResults.filter((file) => file.name.endsWith("hook.unit.test.ts")), + }; + const skippedHook: JsonTestResults = { + ...clean, + testResults: [ + { + ...hookFile!, + assertionResults: hookFile!.assertionResults.map((a) => ({ ...a, status: "skipped" })), + }, + { ...hookFile!, name: "/repo/other.unit.test.ts" }, + ], + }; + + const report = summary( + [ + run("unit", 1, { [`${API}.1.json`]: failingHook }, { exitCode: 1 }), + run("unit", 2, { [`${API}.1.json`]: skippedHook }), + ], + ["flaky-unit-run1", "flaky-unit-run2"], + ); + + expect(report).toEqual({ + flaky: [], + failing: [ + ["hook.unit.test.ts", "(file setup)", "1/1", "failed outside any test (hook or setup)"], + ], + runProblems: [], + }); + }); + + test("reports runs that lost an expected report, crashed, ran nothing in some execution, or never uploaded", () => { + const report = aggregate( + [ + run( + "unit", + 1, + { [`${API}.1.json`]: clean }, + { expectedReports: [`${API}.1.json`, "apps__cli--unit.1.json"] }, + ), + run("unit", 2, { [`${API}.1.json`]: clean }, { exitCode: 1 }), + run("unit", 3, { [`${API}.1.json`]: empty }), + run("unit", 4, {}, { exitCode: null }), + run( + "integration", + 1, + { "apps__cli--integration.1.json": iteration2, [`${API}.1.json`]: clean }, + { + exitCode: 1, + failedInvocations: [ + { reports: ["apps__cli--integration.1.json"], exitCode: 1 }, + { reports: [`${API}.1.json`], exitCode: 1 }, + ], + }, + ), + run("focused", 1, { [`${API}.1.json`]: empty }), + run( + "focused", + 2, + { [`${API}.1.json`]: clean, [`${API}.2.json`]: empty }, + { executions: 2 }, + ), + ], + ["1", "2", "3", "4", "5"] + .map((n) => `flaky-unit-run${n}`) + .concat("flaky-focused-run1", "flaky-focused-run2", "flaky-integration-run1"), + ["focused"], + ); + + expect(report.runProblems).toEqual([ + { name: "flaky-focused-run2", problem: "ran no tests in iteration 2" }, + { + name: "flaky-integration-run1", + problem: `${API}.1.json exited 1 without a failing test (unhandled error or crash)`, + }, + { name: "flaky-unit-run1", problem: "no report for apps__cli--unit.1.json" }, + { + name: "flaky-unit-run2", + problem: `${API}.1.json exited 1 without a failing test (unhandled error or crash)`, + }, + { name: "flaky-unit-run3", problem: "ran no tests; check the filter" }, + { + name: "flaky-unit-run4", + problem: + "test step did not run or finish (setup failure, flaky-check error, timeout, or cancel); see the job log", + }, + { + name: "flaky-unit-run5", + problem: "no results uploaded (the job failed before collecting, such as in setup)", + }, + ]); + expect([report.flaky, report.failing.filter((v) => v.suite !== "integration")]).toEqual([ + [], + [], + ]); + }); +}); + +describe("renderMarkdown", () => { + test("a failing report escapes mentions and locates iterations, and a clean one leads with the marker", () => { + const context = { sha: "0123456789abcdef", runUrl: "https://example.test/run" }; + const failing = renderMarkdown( + aggregate( + [ + run( + "focused", + 1, + { [`${API}.1.json`]: iteration1, [`${API}.2.json`]: iteration2 }, + { executions: 2, exitCode: 1 }, + ), + ], + ["flaky-focused-run1"], + ), + context, + ); + const cleanReport = aggregate( + [run("unit", 1, { [`${API}.1.json`]: clean })], + ["flaky-unit-run1"], + ); + const cleanMarkdown = renderMarkdown(cleanReport, context); + + expect(failing).toContain( + "| focused | sometimes > fails on the second iteration | `packages/api/src/probe.unit.test.ts` | 1/2 | 1.2 | Error: second iteration |", + ); + expect(failing).toContain("| focused | @supabase/api > twin (#2) |"); + expect(commentBody(failing)).toBe(failing); + const truncated = commentBody(failing.repeat(200)); + expect(truncated.length).toBeLessThanOrEqual(65_000); + expect(failing.repeat(200)).toStartWith(truncated.split("\n\n…truncated")[0] + "\n"); + expect(truncated).toEndWith("`flaky-check-report` artifact.\n"); + expect(isClean(cleanReport)).toBe(true); + expect(cleanMarkdown.split("\n").slice(0, 4)).toEqual([ + "", + "## Flaky test check: ✅ no flaky tests", + "", + "Commit `0123456789ab` · unit ×1, 1 test · [workflow run](https://example.test/run)", + ]); + }); +}); diff --git a/.github/scripts/flaky-check/report.ts b/.github/scripts/flaky-check/report.ts new file mode 100644 index 0000000000..cd68d683b7 --- /dev/null +++ b/.github/scripts/flaky-check/report.ts @@ -0,0 +1,368 @@ +import { relative } from "node:path"; +import type { JsonTestResults } from "vitest/node"; + +export const COMMENT_MARKER = ""; +const MAX_ROWS = 50; +// GitHub rejects issue comments longer than 65,536 characters. +const MAX_COMMENT = 65_000; +const MAX_MESSAGE = 200; +const FILE_LEVEL_TITLE = "(file setup)"; +const SKIPPED = new Set(["skipped", "pending", "todo", "disabled"]); + +/** What one matrix job recorded next to its Vitest JSON reports in `meta.json`. */ +export type RunMeta = { + name: string; + suite: string; + run: number; + /** Executions of the suite in this run, each a separate Vitest process. */ + executions: number; + /** `null` when the test step did not run or finish, such as after a setup failure or timeout. */ + exitCode: number | null; + /** Checkout root the reports' absolute file paths are relative to. */ + root: string; + /** Collected report names the run's invocations were expected to write. */ + expectedReports: string[]; + /** Invocations that exited nonzero, with the collected report names each was expected to write. */ + failedInvocations: { reports: string[]; exitCode: number }[]; +}; + +type TestCase = { + file: string; + titles: string[]; + failed: boolean; + skipped: boolean; + /** File-level entries record `beforeAll` and import failures, which no test entry carries. */ + fileLevel: boolean; + message?: string; +}; + +/** One Vitest JSON report; `iteration` tells executions within a run apart. */ +type CollectedReport = { name: string; iteration: number; cases: TestCase[] }; + +export type RunResult = { meta: RunMeta; reports: CollectedReport[] }; + +type Execution = { run: number; iteration: number }; + +type TestVerdict = { + suite: string; + /** Report name without its directory and iteration, such as `whole-stack.docker.lifecycle`. */ + source: string; + file: string; + name: string; + executions: number; + failedIn: Execution[]; + message?: string; +}; + +type RunProblem = { name: string; problem: string }; + +type Report = { + flaky: TestVerdict[]; + failing: TestVerdict[]; + runProblems: RunProblem[]; + suites: { suite: string; runs: number; executions: number; tests: number }[]; +}; + +function firstLine(message: string): string { + return message.split("\n", 1)[0] ?? ""; +} + +/** + * Flattens one Vitest JSON report of a single execution. Each file also gets a file-level entry, + * because a failing `beforeAll` or import only marks the file failed. + */ +export function parseVitestJson(results: JsonTestResults, root: string): TestCase[] { + return results.testResults.flatMap((fileResult) => { + const file = relative(root, fileResult.name); + const tests = fileResult.assertionResults.map((assertion): TestCase => { + const message = assertion.failureMessages?.[0]; + return { + file, + titles: [...assertion.ancestorTitles, assertion.title], + failed: assertion.status === "failed", + skipped: SKIPPED.has(assertion.status), + fileLevel: false, + ...(message === undefined ? {} : { message: firstLine(message) }), + }; + }); + // With failing tests and an empty message, the file status says nothing more about hooks. + const fileFailed = + fileResult.status === "failed" && + (fileResult.message !== "" || !tests.some((test) => test.failed)); + const fileLevel: TestCase = { + file, + titles: [FILE_LEVEL_TITLE], + failed: fileFailed, + // Hooks do not run in a file whose tests were all skipped, so its setup was not exercised. + skipped: !fileFailed && tests.length > 0 && tests.every((test) => test.skipped), + fileLevel: true, + ...(fileFailed + ? { message: firstLine(fileResult.message) || "failed outside any test (hook or setup)" } + : {}), + }; + return [...tests, fileLevel]; + }); +} + +/** `allowEmpty` names suites where running no tests is legitimate, like `focused` with no affected tests. */ +export function aggregate( + results: RunResult[], + expected: string[], + allowEmpty: string[] = [], +): Report { + const verdicts = new Map(); + const runProblems: RunProblem[] = []; + const suites = new Map; executions: number; tests: Set }>(); + + for (const { meta, reports } of results) { + const suite = suites.get(meta.suite) ?? { + runs: new Set(), + executions: meta.executions, + tests: new Set(), + }; + suite.runs.add(meta.run); + suites.set(meta.suite, suite); + + /** Iterations that neither ran a test nor failed, such as a repetition that selected nothing. */ + const emptyIterations: number[] = []; + const iterations = [...new Set(reports.map((report) => report.iteration))].sort( + (a, b) => a - b, + ); + for (const iteration of iterations) { + // Tests sharing a title in one file stay distinct by their order of appearance, and + // scenarios that run one file under different report names stay distinct by report. + const occurrences = new Map(); + let ranTests = 0; + let iterationFailed = false; + const cases = reports + .filter((report) => report.iteration === iteration) + .flatMap((report) => + report.cases.map((testCase) => ({ + source: report.name.replace(/\.\d+\.json$/, ""), + testCase, + })), + ); + for (const { source, testCase } of cases) { + const id = `${source}\0${testCase.file}\0${testCase.titles.join("\0")}`; + const occurrence = (occurrences.get(id) ?? 0) + 1; + occurrences.set(id, occurrence); + if (testCase.skipped) { + continue; + } + const key = `${meta.suite}\0${id}\0${occurrence}`; + if (!testCase.fileLevel) { + ranTests += 1; + suite.tests.add(key); + } + const title = testCase.titles.join(" > "); + const verdict = verdicts.get(key) ?? { + suite: meta.suite, + source: source.replace(/^.*--/, ""), + file: testCase.file, + name: occurrence === 1 ? title : `${title} (#${occurrence})`, + executions: 0, + failedIn: [], + }; + verdict.executions += 1; + if (testCase.failed) { + iterationFailed = true; + verdict.failedIn.push({ run: meta.run, iteration }); + verdict.message ??= testCase.message; + } + verdicts.set(key, verdict); + } + if (ranTests === 0 && !iterationFailed) { + emptyIterations.push(iteration); + } + } + + const collected = new Set(reports.map((report) => report.name)); + const withFailure = new Set( + reports.filter((report) => report.cases.some((c) => c.failed)).map((report) => report.name), + ); + // A failing test elsewhere in the job does not explain another invocation's nonzero exit. + const crashed = meta.failedInvocations.filter( + (invocation) => !invocation.reports.some((name) => withFailure.has(name)), + ); + const missing = meta.expectedReports.filter((name) => !collected.has(name)).sort(); + if (meta.exitCode === null) { + runProblems.push({ + name: meta.name, + problem: + "test step did not run or finish (setup failure, flaky-check error, timeout, or cancel); see the job log", + }); + } else if (reports.length === 0) { + runProblems.push({ + name: meta.name, + problem: `exited ${meta.exitCode} without writing a report`, + }); + } else if (missing.length > 0) { + runProblems.push({ name: meta.name, problem: `no report for ${missing.join(", ")}` }); + } else if (crashed.length > 0) { + runProblems.push({ + name: meta.name, + problem: crashed + .map( + (invocation) => + `${invocation.reports.join(", ") || "an invocation"} exited ${invocation.exitCode} without a failing test (unhandled error or crash)`, + ) + .join("; "), + }); + } else if (emptyIterations.length === iterations.length) { + if (!allowEmpty.includes(meta.suite)) { + runProblems.push({ name: meta.name, problem: "ran no tests; check the filter" }); + } + } else if (emptyIterations.length > 0) { + runProblems.push({ + name: meta.name, + problem: `ran no tests in iteration ${emptyIterations.join(", ")}`, + }); + } + } + + const seen = new Set(results.map(({ meta }) => meta.name)); + for (const name of expected) { + if (!seen.has(name)) { + runProblems.push({ + name, + problem: "no results uploaded (the job failed before collecting, such as in setup)", + }); + } + } + + // A test that several scenarios run under one name gets its source, so rows stay distinguishable. + const sources = new Map>(); + for (const v of verdicts.values()) { + const label = `${v.suite}\0${v.file}\0${v.name}`; + sources.set(label, (sources.get(label) ?? new Set()).add(v.source)); + } + for (const v of verdicts.values()) { + if ((sources.get(`${v.suite}\0${v.file}\0${v.name}`)?.size ?? 0) > 1) { + v.name = `${v.name} [${v.source}]`; + } + } + + const failed = [...verdicts.values()] + .filter((v) => v.failedIn.length > 0) + .sort( + (a, b) => + b.failedIn.length / b.executions - a.failedIn.length / a.executions || + a.file.localeCompare(b.file), + ); + const failsEveryExecution = (v: TestVerdict) => v.failedIn.length === v.executions; + return { + flaky: failed.filter((v) => !failsEveryExecution(v)), + failing: failed.filter(failsEveryExecution), + runProblems: runProblems.sort((a, b) => a.name.localeCompare(b.name)), + suites: [...suites.entries()] + .map(([suite, { runs, executions, tests }]) => ({ + suite, + runs: runs.size, + executions, + tests: tests.size, + })) + .sort((a, b) => a.suite.localeCompare(b.suite)), + }; +} + +export function isClean(report: Report): boolean { + return ( + report.flaky.length === 0 && report.failing.length === 0 && report.runProblems.length === 0 + ); +} + +function cell(value: string): string { + const flat = value.replace(/\s+/g, " ").trim(); + const short = flat.length > MAX_MESSAGE ? `${flat.slice(0, MAX_MESSAGE)}…` : flat; + // The empty comment keeps `@scope/name` in test titles from mentioning GitHub users or teams. + return short.replaceAll("|", "\\|").replaceAll("<", "<").replaceAll("@", "@"); +} + +function where(executions: Execution[], repeated: boolean): string { + return [...executions] + .sort((a, b) => a.run - b.run || a.iteration - b.iteration) + .map((e) => (repeated ? `${e.run}.${e.iteration}` : `${e.run}`)) + .join(", "); +} + +function testTable(title: string, verdicts: TestVerdict[], repeated: Set): string[] { + if (verdicts.length === 0) { + return []; + } + const rows = verdicts + .slice(0, MAX_ROWS) + .map( + (v) => + `| ${v.suite} | ${cell(v.name)} | \`${v.file}\` | ${v.failedIn.length}/${v.executions} | ${where(v.failedIn, repeated.has(v.suite))} | ${cell(v.message ?? "")} |`, + ); + const lines = [ + `### ${title} (${verdicts.length})`, + "", + "| Suite | Test | File | Failed | Where | First failure |", + "| --- | --- | --- | --- | --- | --- |", + ...rows, + ]; + if (verdicts.length > MAX_ROWS) { + lines.push("", `…and ${verdicts.length - MAX_ROWS} more in \`report.json\`.`); + } + return [...lines, ""]; +} + +export function renderMarkdown( + report: Report, + { sha, runUrl }: { sha: string; runUrl: string }, +): string { + const counts = [ + report.flaky.length > 0 ? `${report.flaky.length} flaky` : "", + report.failing.length > 0 ? `${report.failing.length} failed every time` : "", + report.runProblems.length > 0 ? `${report.runProblems.length} runs without results` : "", + ].filter(Boolean); + const heading = isClean(report) ? "✅ no flaky tests" : `⚠️ ${counts.join(", ")}`; + const suites = report.suites + .map((s) => + s.tests === 0 + ? `${s.suite} ×${s.runs} (no tests ran)` + : `${s.suite} ×${s.runs}${s.executions > 1 ? ` (${s.executions} executions each)` : ""}, ${s.tests} test${s.tests === 1 ? "" : "s"}`, + ) + .join(" · "); + const repeated = new Set(report.suites.filter((s) => s.executions > 1).map((s) => s.suite)); + + const lines = [ + COMMENT_MARKER, + `## Flaky test check: ${heading}`, + "", + `Commit \`${sha.slice(0, 12)}\` · ${suites || "no runs"} · [workflow run](${runUrl})`, + "", + ...testTable("Flaky tests", report.flaky, repeated), + ...testTable("Failed every time", report.failing, repeated), + ]; + if (report.runProblems.length > 0) { + lines.push( + `### Runs without results (${report.runProblems.length})`, + "", + "| Run | Problem |", + "| --- | --- |", + ...report.runProblems.slice(0, MAX_ROWS).map((p) => `| \`${p.name}\` | ${cell(p.problem)} |`), + ...(report.runProblems.length > MAX_ROWS + ? ["", `…and ${report.runProblems.length - MAX_ROWS} more in \`report.json\`.`] + : []), + "", + ); + } + lines.push( + "Each execution is a separate Vitest process; `Where` lists failing runs, as `run.iteration` for suites executed more than once per run. The full data is in the `flaky-check-report` artifact.", + ); + return `${lines.join("\n")}\n`; +} + +/** The report cut to fit a PR comment; the job summary and artifact keep the full version. */ +export function commentBody(markdown: string): string { + const note = + "\n\n…truncated; the full report is in the job summary and the `flaky-check-report` artifact.\n"; + if (markdown.length <= MAX_COMMENT) { + return markdown; + } + // Cutting at a line end keeps table rows, mention escapes, and surrogate pairs whole. + const head = markdown.slice(0, MAX_COMMENT - note.length); + return head.slice(0, head.lastIndexOf("\n")) + note; +} diff --git a/.github/scripts/sync-artifacts-catalog.test.ts b/.github/scripts/sync-artifacts-catalog.test.ts index d969bcffea..648b4d65a4 100644 --- a/.github/scripts/sync-artifacts-catalog.test.ts +++ b/.github/scripts/sync-artifacts-catalog.test.ts @@ -340,6 +340,42 @@ describe("planSlimUpdates", () => { ]); }); + test("a held service skips the upgrade with a warning but still takes the pinned upstream's hotfix", () => { + const analyticsFixture = `const workloadCatalog = { + analytics: definition( + "analytics", + { + upstreamVersion: "v1.50.15", + revision: 1, + image: "ghcr.io/supabase/cli/analytics:v1.50.15-r1@sha256:4444444444444444444444444444444444444444444444444444444444444", + natives: {}, + }, + "bin/analytics", + ), +}; +`; + + const { updates, warnings } = planSlimUpdates(analyticsFixture, "analytics", [ + "analytics-v1.50.15-r2", + "analytics-v1.53.1-r0", + ]); + + expect(updates).toEqual([ + { + kind: "hotfix", + line: undefined, + branch: "slim-hotfix/analytics", + title: "chore(stack): pin analytics v1.50.15-r2", + fromRelease: "v1.50.15-r1", + toUpstream: "v1.50.15", + toRelease: "v1.50.15-r2", + }, + ]); + expect(warnings).toEqual([ + "::warning ::analytics upgrade to v1.53.1-r0 held at v1.50.15: Logflare 1.52+ breaks Postgres-backend endpoint queries (https://github.com/Logflare/logflare/issues/4158).", + ]); + }); + test("an older committed upstream is a backlog republish: it plans nothing", () => { const { updates, warnings } = planSlimUpdates(plannerFixture, "postgrest", [ "postgrest-v16.1-r0", diff --git a/.github/scripts/sync-artifacts-catalog.ts b/.github/scripts/sync-artifacts-catalog.ts index 5cea7a27e3..bce8748fb9 100644 --- a/.github/scripts/sync-artifacts-catalog.ts +++ b/.github/scripts/sync-artifacts-catalog.ts @@ -812,6 +812,16 @@ export interface PlanSlimUpdatesResult { readonly warnings: ReadonlyArray; } +/** + * Services whose pinned upstream must not be upgraded automatically, with the reason surfaced in + * the planner warning. Hotfix revisions of the pinned upstream still apply; delete the entry to + * resume upgrades. + */ +const UPGRADE_HOLDS: Readonly> = { + analytics: + "Logflare 1.52+ breaks Postgres-backend endpoint queries (https://github.com/Logflare/logflare/issues/4158)", +}; + /** Groups every candidate release tag under one bucket, for a service with no additional pins. */ const SINGLE_LINE_KEY = "*"; @@ -931,7 +941,18 @@ export function planSlimUpdates( (upstream) => compareOnLine(upstream, pinned.upstream) !== undefined, ); - if (best !== undefined && compareOnLine(best.upstream, pinned.upstream) === 1) { + const isUpgrade = best !== undefined && compareOnLine(best.upstream, pinned.upstream) === 1; + const hold = UPGRADE_HOLDS[service]; + if (isUpgrade && hold !== undefined) { + warnings.push( + workflowCommand( + "warning", + `${service} upgrade to ${best.upstream}-r${best.revision} held at ${pinned.upstream}: ${hold}.`, + ), + ); + } + + if (isUpgrade && hold === undefined) { updates.push( buildUpdate("upgrade", service, line, hasLines, pinned, best.upstream, best.revision), ); diff --git a/.github/workflows/flaky-check.yml b/.github/workflows/flaky-check.yml new file mode 100644 index 0000000000..35e5b38375 --- /dev/null +++ b/.github/workflows/flaky-check.yml @@ -0,0 +1,243 @@ +name: Flaky check + +# Runs one commit on many fresh runners and reports tests that both passed and failed. The logic +# lives in .github/scripts/flaky-check; triggers and usage are in .github/MAINTAINERS.md. +on: + schedule: + - cron: "17 3 * * *" + - cron: "47 3 * * 0" + pull_request: + types: + - labeled + workflow_dispatch: + inputs: + ref: + description: Branch, tag, or SHA to test (defaults to the branch this runs from) + type: string + default: "" + suites: + description: Comma-separated subset of unit,integration,focused,e2e,stack-e2e + type: string + default: unit,integration + runs: + description: Parallel runs per suite (1-30) + type: number + default: 10 + repeats: + description: Extra executions per focused run, each in a separate Vitest process + type: number + default: 4 + filter: + description: >- + Space-separated vitest file filters for unit, integration, and focused. Focused + repeats these instead of the tests affected by changes since develop when set. + type: string + default: "" + +permissions: + contents: read + +# Other label events still start a run; a unique group keeps them from cancelling a real one. +concurrency: + group: >- + flaky-check-${{ github.event.pull_request.number || github.ref }}-${{ github.event.schedule || github.event_name }}${{ + (github.event.action == 'labeled' && github.event.label.name != 'run-flaky-check' + && format('-noop-{0}', github.run_id)) + || '' + }}${{ github.event_name == 'workflow_dispatch' && format('-{0}', github.run_id) || '' }} + cancel-in-progress: true + +env: + # The CLI comes from this workflow's revision, so runs against older refs can use it. + FLAKY_CLI: .flaky-tooling/.github/scripts/flaky-check/cli.ts + +jobs: + plan: + if: | + (github.event_name == 'schedule' && github.repository == 'supabase/cli') || + github.event_name == 'workflow_dispatch' || + (github.event_name == 'pull_request' && github.event.label.name == 'run-flaky-check') + name: Plan runs + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + outputs: + sha: ${{ steps.plan.outputs.sha }} + base: ${{ steps.plan.outputs.base }} + filter: ${{ steps.plan.outputs.filter }} + matrix: ${{ steps.plan.outputs.matrix }} + steps: + - name: Checkout flaky-check tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: .flaky-tooling + sparse-checkout: .github/scripts/flaky-check + persist-credentials: false + + - name: Install Bun + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + version: 2026.9.0 + install: true + install_args: bun + working_directory: .flaky-tooling + + - name: Plan runs + id: plan + env: + GH_TOKEN: ${{ github.token }} + EVENT_NAME: ${{ github.event_name }} + SCHEDULE: ${{ github.event.schedule }} + EVENT_SHA: ${{ github.sha }} + PR_BASE_REF: ${{ github.base_ref }} + INPUT_REF: ${{ inputs.ref }} + INPUT_SUITES: ${{ inputs.suites }} + INPUT_RUNS: ${{ inputs.runs }} + INPUT_REPEATS: ${{ inputs.repeats }} + INPUT_FILTER: ${{ inputs.filter }} + run: bun "$FLAKY_CLI" plan + + # Removing the label lets a maintainer re-add it to start another run. Fork PRs get a + # read-only token, so the label stays there and must be removed by hand. + - name: Remove trigger label + if: >- + always() && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: | + gh api --method DELETE "repos/$GITHUB_REPOSITORY/issues/$PR_NUMBER/labels/run-flaky-check" >/dev/null \ + || echo "::warning::Unable to remove the run-flaky-check label." + + run: + needs: plan + name: ${{ matrix.name }} + runs-on: ${{ matrix.runner }} + timeout-minutes: ${{ matrix.timeout }} + env: + SUPABASE_STACK_TEST_ENGINE: ${{ matrix.runtime == 'podman' && 'podman' || 'docker' }} + strategy: + fail-fast: false + matrix: ${{ fromJSON(needs.plan.outputs.matrix) }} + steps: + - name: Checkout + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ needs.plan.outputs.sha }} + fetch-depth: ${{ matrix.fetchDepth }} + persist-credentials: false + + - name: Checkout flaky-check tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: .flaky-tooling + sparse-checkout: .github/scripts/flaky-check + persist-credentials: false + + - name: Setup + uses: ./.github/actions/setup + with: + dependency-firewall-token: ${{ secrets.DF_FIREWALL_TOKEN }} + sticky-disks: "true" + docker-images: ${{ matrix.suite != 'unit' && (matrix.suite != 'stack-e2e' || matrix.runtime == 'docker' || matrix.scenario == 'lifecycle' && matrix.runtime == 'native') }} + native-artifacts: ${{ matrix.runtime == 'native' }} + + - name: Setup Podman + if: matrix.runtime == 'podman' + uses: ./.github/actions/setup-podman + + - name: Build CLI + if: matrix.suite == 'e2e' + run: pnpm exec turbo run supabase#build + + - name: Run tests + id: run + env: + MATRIX: ${{ toJSON(matrix) }} + BASE_REF: ${{ needs.plan.outputs.base }} + FILTER: ${{ needs.plan.outputs.filter }} + run: bun "$FLAKY_CLI" run + + - name: Collect results + id: collect + if: always() + env: + MATRIX: ${{ toJSON(matrix) }} + EXIT_CODE: ${{ steps.run.outputs.exit-code }} + EXPECTED_REPORTS: ${{ steps.run.outputs.expected-reports }} + FAILED_INVOCATIONS: ${{ steps.run.outputs.failed-invocations }} + run: bun "$FLAKY_CLI" collect + + - name: Upload results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: ${{ matrix.name }} + path: ${{ steps.collect.outputs.dir }} + retention-days: 14 + if-no-files-found: error + overwrite: true + + # Releases /var/lib/docker so the image disk unmounts cleanly. + - name: Stop Docker + if: always() && matrix.suite != 'unit' && (matrix.suite != 'stack-e2e' || matrix.runtime == 'docker' || matrix.scenario == 'lifecycle' && matrix.runtime == 'native') + run: sudo systemctl stop docker.socket docker.service + + report: + needs: [plan, run] + if: ${{ !cancelled() && needs.plan.result == 'success' }} + name: Report flaky tests + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + steps: + - name: Checkout flaky-check tooling + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: .flaky-tooling + sparse-checkout: .github/scripts/flaky-check + persist-credentials: false + + - name: Install Bun + uses: jdx/mise-action@c2a87611a18de5b3828c5652fe268e992400cb5c # v4.3.0 + with: + version: 2026.9.0 + install: true + install_args: bun + working_directory: .flaky-tooling + + - name: Download results + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + pattern: flaky-*-run* + path: results + + - name: Build report + env: + MATRIX: ${{ needs.plan.outputs.matrix }} + SHA: ${{ needs.plan.outputs.sha }} + FILTER: ${{ needs.plan.outputs.filter }} + run: bun "$FLAKY_CLI" report results flaky-report + + - name: Upload report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: flaky-check-report + path: flaky-report + retention-days: 30 + overwrite: true + + - name: Comment on PR + if: >- + always() && + github.event_name == 'pull_request' && + github.event.pull_request.head.repo.full_name == github.repository + env: + GH_TOKEN: ${{ github.token }} + PR_NUMBER: ${{ github.event.pull_request.number }} + run: bun "$FLAKY_CLI" comment flaky-report/report.md diff --git a/.github/workflows/github-scripts-ci.yml b/.github/workflows/github-scripts-ci.yml index ee6a98f27c..55484ff381 100644 --- a/.github/workflows/github-scripts-ci.yml +++ b/.github/workflows/github-scripts-ci.yml @@ -21,6 +21,7 @@ on: - ".github/sync-branches/**" - "apps/cli/scripts/analyze-commits-title.js" - ".github/workflows/github-scripts-ci.yml" + - ".github/workflows/flaky-check.yml" - ".github/workflows/mirror-slim-image.yml" permissions: {} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 050fc254b4..fbf89feabb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -226,7 +226,7 @@ jobs: matrix: # Linux coverage for these files comes from the integration job, which # runs the whole stack integration project. - os: [macos-latest, windows-latest] + os: [blacksmith-6vcpu-macos-latest, windows-latest] steps: - name: Checkout uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -374,7 +374,8 @@ jobs: - { suite: cli, shard: 3, shards: 5, name: cli 3/5 } - { suite: cli, shard: 4, shards: 5, name: cli 4/5 } - { suite: cli, shard: 5, shards: 5, name: cli 5/5 } - - { suite: cli-e2e, name: cli-e2e } + - { suite: cli-e2e, shard: 1, shards: 2, name: cli-e2e 1/2 } + - { suite: cli-e2e, shard: 2, shards: 2, name: cli-e2e 2/2 } steps: - name: Checkout uses: useblacksmith/checkout@6fd481652155169ed4d2f25ebaf97464f685175f # v1.0.0-beta @@ -403,7 +404,7 @@ jobs: - name: Run cli-e2e end-to-end tests if: matrix.suite == 'cli-e2e' - run: pnpm exec turbo run test:e2e:run --only --filter=@supabase/cli-e2e + run: pnpm exec turbo run test:e2e:run --only --filter=@supabase/cli-e2e -- --shard=${{ matrix.shard }}/${{ matrix.shards }} # Vitest writes its results cache under a sha1-named directory. The refresh script # (apps/cli/scripts/refresh-e2e-timings.ts) merges these into tests/e2e-timings.json. diff --git a/.gitignore b/.gitignore index ff9c4a0605..8873eb8d7c 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,8 @@ coverage/ .agents/.repos/effect-v3 .repos/slim-services/ .worktrees/ +# Vitest JSON reports written by .github/scripts/flaky-check. +.flaky-results/ .supabase/ erl_crash.dump # Stray `supabase` project dir created by running the CLI at the repo root diff --git a/.oxlintrc.json b/.oxlintrc.json index 687e5608a6..08ff534a2b 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -34,6 +34,16 @@ "typescript/no-base-to-string": "off" } }, + { + // Management API calls go through the typed `@supabase/api` client so they emit the + // `api.operation` span; tests build stub URLs freely. + "files": ["apps/cli/src/**"], + "excludeFiles": ["**/*.test.ts"], + "jsPlugins": ["./tools/oxlint/supabase-plugin.js"], + "rules": { + "supabase/no-raw-management-api-url": "error" + } + }, { // Ambient env reads go through `CliConfigValues` (registry names) or the shared env-option // helpers, so a stray `process.env` can't bypass the flag > shell > .env > config order. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 05a2ee4c36..ae3a96014d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -234,7 +234,7 @@ Recording proxies CLI traffic to the Supabase staging API. Provide a staging acc SUPABASE_ACCESS_TOKEN= SUPABASE_TEST_PROJECT_REF= SUPABASE_STAGING_URL= pnpm run record ``` -Review the generated files in `apps/cli-e2e/fixtures/recorded/` before committing — verify that no real tokens, UUIDs, or project refs appear (they should be replaced with `__ACCESS_TOKEN__`, `__UUID__`, `__PROJECT_REF__` placeholders). +Review the generated files in `apps/cli-e2e/fixtures/recorded/` before committing — verify that no real tokens, UUIDs, or project refs appear (they should be replaced with `__ACCESS_TOKEN__` and `__UUID__` placeholders and the fixed `replayfixtureprojref` ref). ### Verifying fixtures diff --git a/apps/cli-e2e/AGENTS.md b/apps/cli-e2e/AGENTS.md index 5182804955..88cb932497 100644 --- a/apps/cli-e2e/AGENTS.md +++ b/apps/cli-e2e/AGENTS.md @@ -119,23 +119,23 @@ The fixture for this test is hand-authored in `fixtures/scenarios//interac ## Writing assertions that work in both modes -In **replay mode**, fixture data contains the literal placeholder string `__PROJECT_REF__` — the CLI renders it as-is. In **record mode**, the CLI renders the real 20-char ref. +In **replay mode**, fixture data contains one fixed, schema-valid 20-char ref (`REPLAY_PROJECT_REF` in `src/server/placeholder.ts`) in place of every project ref and org slug — the CLI renders it as-is. In **record mode**, the CLI renders the real 20-char ref. -Never assert on the literal placeholder in a way that breaks in record mode: +Never assert on the literal replay ref in a way that breaks in record mode: ```typescript // WRONG — fails in record mode -expect(result.stdout).toContain("__PROJECT_REF__"); +expect(result.stdout).toContain(REPLAY_PROJECT_REF); -// CORRECT — matches real ref in record mode, placeholder in replay -expect(result.stdout).toMatch(/[a-z]{20}|__PROJECT_REF__/); +// CORRECT — matches the real ref in record mode and the replay ref in replay +expect(result.stdout).toMatch(/[a-z]{20}/); ``` For JSON output assertions, use `toMatchObject` with `expect.any(String)` for ref fields so they pass regardless of whether the value is a real ref or the placeholder. ## Global setup (`tests/setup.ts`) -In **replay mode**: `projectRef` and `orgId` are set to default values (`"aaaaaaaaaaaaaaaaaaaa"` and the same). No API calls are made. +In **replay mode**: `projectRef` and `orgId` are set to `REPLAY_PROJECT_REF`. No API calls are made. In **record mode**: global setup resolves the org, deletes any orphaned test projects by name (from previous failed runs), creates a fresh `cli-e2e-test` project, and provides its ref via Vitest's `inject()` mechanism. This project is deleted in the global teardown. diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_storage_v1_object_cli_e2e_bucket/2.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_storage_v1_object_cli_e2e_bucket/2.response.json index 5961a093df..a841c9cf4f 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_storage_v1_object_cli_e2e_bucket/2.response.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_storage_v1_object_cli_e2e_bucket/2.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_storage_v1_object_cli_e2e_bucket/default.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_storage_v1_object_cli_e2e_bucket/default.response.json index 3fc284cab3..3f5b898b23 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_storage_v1_object_cli_e2e_bucket/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_storage_v1_object_cli_e2e_bucket/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_branches___PROJECT_REF__/default.request.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_branches_replayfixtureprojref/default.request.json similarity index 82% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_branches___PROJECT_REF__/default.request.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_branches_replayfixtureprojref/default.request.json index 141533f161..3efce9a4af 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_branches___PROJECT_REF__/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_branches_replayfixtureprojref/default.request.json @@ -1,6 +1,6 @@ { "method": "DELETE", - "path": "/v1/branches/__PROJECT_REF__", + "path": "/v1/branches/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_branches___PROJECT_REF__/default.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_branches_replayfixtureprojref/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_branches___PROJECT_REF__/default.response.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_branches_replayfixtureprojref/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/2.request.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/2.request.json similarity index 82% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/2.request.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/2.request.json index 1471da09df..3155ebf1db 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/2.request.json @@ -1,6 +1,6 @@ { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/2.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/2.response.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/default.request.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/default.request.json similarity index 82% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/default.request.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/default.request.json index 1471da09df..3155ebf1db 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/default.request.json @@ -1,6 +1,6 @@ { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/default.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/default.response.json similarity index 80% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/default.response.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/default.response.json index f16972d2f7..3929209ea4 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF__/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref/default.response.json @@ -10,7 +10,7 @@ "body": { "id": 711274, "name": "cli-e2e-test", - "ref": "__PROJECT_REF__", - "slug": "__PROJECT_REF__" + "ref": "replayfixtureprojref", + "slug": "replayfixtureprojref" } } diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___vanity_subdomain/default.request.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_custom_hostname/default.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___vanity_subdomain/default.request.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_custom_hostname/default.request.json index b88a0bf0a9..b13ee1ff5c 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___vanity_subdomain/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_custom_hostname/default.request.json @@ -1,6 +1,6 @@ { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain", + "path": "/v1/projects/replayfixtureprojref/custom-hostname", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___custom_hostname/default.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_custom_hostname/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___custom_hostname/default.response.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_custom_hostname/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___custom_hostname/default.request.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_functions_hello_world/default.request.json similarity index 76% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___custom_hostname/default.request.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_functions_hello_world/default.request.json index f1701f2109..2a26e11bec 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___custom_hostname/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_functions_hello_world/default.request.json @@ -1,6 +1,6 @@ { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname", + "path": "/v1/projects/replayfixtureprojref/functions/hello-world", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___functions_hello_world/default.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_functions_hello_world/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___functions_hello_world/default.response.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_functions_hello_world/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___network_bans/2.request.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_network_bans/2.request.json similarity index 85% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___network_bans/2.request.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_network_bans/2.request.json index c8e02c2037..dbcd6660d4 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___network_bans/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_network_bans/2.request.json @@ -1,6 +1,6 @@ { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/network-bans", + "path": "/v1/projects/replayfixtureprojref/network-bans", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___network_bans/2.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_network_bans/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___network_bans/2.response.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_network_bans/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___network_bans/default.request.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_network_bans/default.request.json similarity index 85% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___network_bans/default.request.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_network_bans/default.request.json index af07df0517..73f2a787e1 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___network_bans/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_network_bans/default.request.json @@ -1,6 +1,6 @@ { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/network-bans", + "path": "/v1/projects/replayfixtureprojref/network-bans", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___network_bans/default.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_network_bans/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___network_bans/default.response.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_network_bans/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___secrets/2.request.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_secrets/2.request.json similarity index 84% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___secrets/2.request.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_secrets/2.request.json index b6afc7d56e..a4de2f8fc2 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___secrets/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_secrets/2.request.json @@ -1,6 +1,6 @@ { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___secrets/2.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_secrets/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___secrets/2.response.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_secrets/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___secrets/default.request.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_secrets/default.request.json similarity index 84% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___secrets/default.request.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_secrets/default.request.json index 09f50d2661..7296dbc6f6 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___secrets/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_secrets/default.request.json @@ -1,6 +1,6 @@ { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___secrets/default.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_secrets/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___secrets/default.response.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_secrets/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___functions_hello_world/default.request.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_vanity_subdomain/default.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___functions_hello_world/default.request.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_vanity_subdomain/default.request.json index 4b5e2fa0dc..b289817eed 100644 --- a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___functions_hello_world/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_vanity_subdomain/default.request.json @@ -1,6 +1,6 @@ { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/functions/hello-world", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___vanity_subdomain/default.response.json b/apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_vanity_subdomain/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/DELETE_v1_projects___PROJECT_REF___vanity_subdomain/default.response.json rename to apps/cli-e2e/fixtures/recorded/DELETE_v1_projects_replayfixtureprojref_vanity_subdomain/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_storage_v1_bucket/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_storage_v1_bucket/2.response.json index d89f7e1a63..122a8e415e 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_storage_v1_bucket/2.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_storage_v1_bucket/2.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/recorded/GET_storage_v1_bucket/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_storage_v1_bucket/default.response.json index d89f7e1a63..122a8e415e 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_storage_v1_bucket/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_storage_v1_bucket/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/recorded/GET_storage_v1_object_cli_e2e_bucket_hello_txt/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_storage_v1_object_cli_e2e_bucket_hello_txt/default.response.json index 69a8834ecd..dcfc43224a 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_storage_v1_object_cli_e2e_bucket_hello_txt/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_storage_v1_object_cli_e2e_bucket_hello_txt/default.response.json @@ -8,7 +8,7 @@ "last-modified": "Mon, 27 Apr 2026 10:30:57 GMT", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-robots-tag": "none", "x-smart-cdn": "true" diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_branches_replayfixtureprojref/default.request.json similarity index 82% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_branches_replayfixtureprojref/default.request.json index 77c0083556..f9c59b0d8e 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_branches_replayfixtureprojref/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/branches/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_branches___PROJECT_REF__/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_branches_replayfixtureprojref/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_branches___PROJECT_REF__/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_branches_replayfixtureprojref/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/2.response.json index 3bfe42b35f..75ad025153 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/2.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/2.response.json @@ -9,13 +9,13 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Org" }, { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "CLIPort-TEST" } ] diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/3.response.json index eefab335ea..bb6a5bedfb 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/3.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/3.response.json @@ -9,13 +9,13 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Org" }, { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "CLIPort-TEST" } ] diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/4.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/4.response.json index 63df5e6545..17adb27258 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/4.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/4.response.json @@ -9,13 +9,13 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Org" }, { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "CLIPort-TEST" } ] diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/default.response.json index 3bfe42b35f..75ad025153 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations/default.response.json @@ -9,13 +9,13 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Org" }, { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "CLIPort-TEST" } ] diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/2.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/2.request.json index c60f7965d4..e79186c1ab 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/organizations/__PROJECT_REF__/entitlements", + "path": "/v1/organizations/replayfixtureprojref/entitlements", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/3.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/3.request.json index 9c54657855..e79186c1ab 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/advisors/security", + "path": "/v1/organizations/replayfixtureprojref/entitlements", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/4.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/4.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/4.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/4.request.json index c60f7965d4..e79186c1ab 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/4.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/4.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/organizations/__PROJECT_REF__/entitlements", + "path": "/v1/organizations/replayfixtureprojref/entitlements", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/4.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/4.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/4.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/4.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_pooler/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/default.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_pooler/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/default.request.json index 3620f448c6..e79186c1ab 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_pooler/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/pooler", + "path": "/v1/organizations/replayfixtureprojref/entitlements", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_organizations_replayfixtureprojref_entitlements/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects/2.response.json index ea0c2328ee..b79ece0a34 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects/2.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects/2.response.json @@ -9,15 +9,15 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Project", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.084", "postgres_engine": "17", "release_channel": "ga" @@ -25,15 +25,15 @@ "created_at": "2000-01-01T00:00:00Z" }, { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects/3.response.json index 7a3b6a1879..7d1abfe29b 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects/3.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects/3.response.json @@ -9,15 +9,15 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Project", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.084", "postgres_engine": "17", "release_channel": "ga" @@ -25,15 +25,15 @@ "created_at": "2000-01-01T00:00:00Z" }, { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects/4.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects/4.response.json index 911ad4afd3..0bfd2e70f2 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects/4.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects/4.response.json @@ -9,15 +9,15 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Project", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.084", "postgres_engine": "17", "release_channel": "ga" @@ -25,15 +25,15 @@ "created_at": "2000-01-01T00:00:00Z" }, { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects/5.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects/5.response.json index 86c1c85fb5..61707be8a6 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects/5.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects/5.response.json @@ -9,15 +9,15 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Project", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.084", "postgres_engine": "17", "release_channel": "ga" @@ -25,15 +25,15 @@ "created_at": "2000-01-01T00:00:00Z" }, { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" @@ -41,15 +41,15 @@ "created_at": "2000-01-01T00:00:00Z" }, { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "my-project", "region": "us-east-1", "status": "COMING_UP", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects/default.response.json index ea0c2328ee..b79ece0a34 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects/default.response.json @@ -9,15 +9,15 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Project", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.084", "postgres_engine": "17", "release_channel": "ga" @@ -25,15 +25,15 @@ "created_at": "2000-01-01T00:00:00Z" }, { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/default.request.json deleted file mode 100644 index 9c54657855..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/advisors/security", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches_my_branch/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches_my_branch/default.request.json deleted file mode 100644 index f38ef4349b..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches_my_branch/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches/my-branch", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/2.request.json deleted file mode 100644 index 63189a3535..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/2.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/3.request.json deleted file mode 100644 index 63189a3535..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/3.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/default.request.json deleted file mode 100644 index 63189a3535..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/default.request.json deleted file mode 100644 index 645fc3d4f4..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/default.request.json deleted file mode 100644 index e51a9af732..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/storage", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___database_backups/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___database_backups/default.request.json deleted file mode 100644 index ae8adec0cc..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___database_backups/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/database/backups", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/default.request.json deleted file mode 100644 index e3a71b1a58..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/functions", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/2.request.json deleted file mode 100644 index 7f7b19d1bf..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/2.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/3.request.json deleted file mode 100644 index 7f7b19d1bf..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/3.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/default.request.json deleted file mode 100644 index 7f7b19d1bf..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___pgsodium/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___pgsodium/default.request.json deleted file mode 100644 index 270dcdd57b..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___pgsodium/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/pgsodium", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/default.request.json deleted file mode 100644 index 7f159f3ec9..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/postgrest", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/2.request.json deleted file mode 100644 index f37a9dc97c..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/2.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/secrets", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/default.request.json deleted file mode 100644 index f37a9dc97c..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/secrets", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/2.request.json deleted file mode 100644 index a5c051c329..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/2.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/ssl-enforcement", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/default.request.json deleted file mode 100644 index a5c051c329..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/ssl-enforcement", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___vanity_subdomain/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___vanity_subdomain/default.request.json deleted file mode 100644 index 59ba9879d8..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___vanity_subdomain/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/2.request.json similarity index 82% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/2.request.json index 77c0083556..9c3ddd0b73 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/2.response.json similarity index 68% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/2.response.json index 50c697a48b..a21ea54e57 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/2.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/2.response.json @@ -8,15 +8,15 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/3.request.json similarity index 82% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/3.request.json index 77c0083556..9c3ddd0b73 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/3.response.json similarity index 68% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/3.response.json index 0df4659eab..1729b4c75f 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/3.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/3.response.json @@ -8,15 +8,15 @@ "x-ratelimit-reset": "58" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/4.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/4.request.json similarity index 82% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/4.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/4.request.json index 77c0083556..9c3ddd0b73 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/4.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/4.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/4.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/4.response.json similarity index 68% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/4.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/4.response.json index 75b639f755..a736fec22f 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/4.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/4.response.json @@ -8,15 +8,15 @@ "x-ratelimit-reset": "54" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_branches___PROJECT_REF__/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/default.request.json similarity index 82% rename from apps/cli-e2e/fixtures/recorded/GET_v1_branches___PROJECT_REF__/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/default.request.json index 91ec0cfd60..9c3ddd0b73 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_branches___PROJECT_REF__/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/branches/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/default.response.json similarity index 68% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/default.response.json index 837263accc..1415f96058 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF__/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref/default.response.json @@ -8,15 +8,15 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/2.request.json new file mode 100644 index 0000000000..b93e07e013 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/2.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/advisors/performance", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_performance/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_performance/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/default.request.json new file mode 100644 index 0000000000..b93e07e013 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/advisors/performance", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_performance/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_performance/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_performance/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/2.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/2.request.json index 9c54657855..436b3c7567 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/advisors/security", + "path": "/v1/projects/replayfixtureprojref/advisors/security", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/3.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/3.request.json index c60f7965d4..436b3c7567 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/organizations/__PROJECT_REF__/entitlements", + "path": "/v1/projects/replayfixtureprojref/advisors/security", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/default.request.json new file mode 100644 index 0000000000..436b3c7567 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/advisors/security", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_security/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_advisors_security/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/2.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/2.request.json index 90e1a616cb..6d1c89bb01 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches", + "path": "/v1/projects/replayfixtureprojref/api-keys", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/3.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/3.request.json index e3a71b1a58..6d1c89bb01 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/functions", + "path": "/v1/projects/replayfixtureprojref/api-keys", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/default.request.json similarity index 81% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/default.request.json index 6ae3fb5570..5af6c5c005 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/api-keys", + "path": "/v1/projects/replayfixtureprojref/api-keys", "query": { "reveal": "true" }, diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_api_keys/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_billing_addons/2.request.json similarity index 78% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_billing_addons/2.request.json index 34474f2c64..f286c8407a 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_billing_addons/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth", + "path": "/v1/projects/replayfixtureprojref/billing/addons", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___billing_addons/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_billing_addons/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___billing_addons/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_billing_addons/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___billing_addons/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_billing_addons/default.request.json similarity index 78% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___billing_addons/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_billing_addons/default.request.json index ad666f2604..f286c8407a 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___billing_addons/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_billing_addons/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/billing/addons", + "path": "/v1/projects/replayfixtureprojref/billing/addons", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___billing_addons/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_billing_addons/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___billing_addons/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_billing_addons/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/2.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/2.request.json index e3a71b1a58..7de75b610d 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/functions", + "path": "/v1/projects/replayfixtureprojref/branches", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/default.request.json new file mode 100644 index 0000000000..7de75b610d --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/branches", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches_my_branch/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches_my_branch/default.request.json new file mode 100644 index 0000000000..daf9168dd0 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches_my_branch/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/branches/my-branch", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches_my_branch/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches_my_branch/default.response.json similarity index 92% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches_my_branch/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches_my_branch/default.response.json index 1c3c6655d5..7f16ebf2f2 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches_my_branch/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_branches_my_branch/default.response.json @@ -11,7 +11,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/2.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/2.request.json index a53b9601cf..8f1ae1d649 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/storage", + "path": "/v1/projects/replayfixtureprojref/config/auth", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/2.response.json similarity index 99% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/2.response.json index ade1f10f11..094d8a0403 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/2.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/2.response.json @@ -40,6 +40,7 @@ "hook_before_user_created_secrets": null, "nimbus_oauth_client_id": null, "nimbus_oauth_client_secret": null, + "nimbus_oauth_email_optional": null, "external_anonymous_users_enabled": false, "external_apple_additional_client_ids": null, "external_apple_client_id": null, diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/3.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/3.request.json index fb1539a87b..8f1ae1d649 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/postgrest", + "path": "/v1/projects/replayfixtureprojref/config/auth", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/3.response.json similarity index 99% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/3.response.json index 9ae9974882..287ee554dc 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/3.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/3.response.json @@ -40,6 +40,7 @@ "hook_before_user_created_secrets": null, "nimbus_oauth_client_id": null, "nimbus_oauth_client_secret": null, + "nimbus_oauth_email_optional": null, "external_anonymous_users_enabled": false, "external_apple_additional_client_ids": null, "external_apple_client_id": null, diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/default.request.json new file mode 100644 index 0000000000..de978f043d --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/config/auth", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/default.response.json similarity index 99% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/default.response.json index e4adf44716..49f7407b5b 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth/default.response.json @@ -40,6 +40,7 @@ "hook_before_user_created_secrets": null, "nimbus_oauth_client_id": null, "nimbus_oauth_client_secret": null, + "nimbus_oauth_email_optional": null, "external_anonymous_users_enabled": false, "external_apple_additional_client_ids": null, "external_apple_client_id": null, diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/2.request.json new file mode 100644 index 0000000000..dc27bb7cb1 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/2.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/3.request.json new file mode 100644 index 0000000000..dc27bb7cb1 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/3.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.request.json new file mode 100644 index 0000000000..dc27bb7cb1 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/2.request.json new file mode 100644 index 0000000000..2b5e4cebb8 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/2.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/3.request.json new file mode 100644 index 0000000000..2b5e4cebb8 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/3.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/4.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/4.request.json similarity index 72% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/4.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/4.request.json index 63189a3535..2b5e4cebb8 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/4.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/4.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/4.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/4.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/4.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/4.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/5.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/5.request.json similarity index 72% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/5.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/5.request.json index 63189a3535..2b5e4cebb8 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/5.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/5.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/5.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/5.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/5.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/5.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/6.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/6.request.json similarity index 72% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/6.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/6.request.json index 63189a3535..2b5e4cebb8 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/6.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/6.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/6.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/6.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/6.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/6.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/7.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/7.request.json similarity index 72% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/7.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/7.request.json index 63189a3535..2b5e4cebb8 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/7.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/7.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/7.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/7.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/7.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/7.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/default.request.json new file mode 100644 index 0000000000..2b5e4cebb8 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers___UUID__/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_auth_sso_providers___UUID__/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_pooler/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_pooler/default.request.json new file mode 100644 index 0000000000..504cea6c4a --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_pooler/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/config/database/pooler", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_pooler/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_pooler/default.response.json similarity index 58% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_pooler/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_pooler/default.response.json index 2cc616268a..ab814c18a7 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_pooler/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_pooler/default.response.json @@ -9,18 +9,18 @@ }, "body": [ { - "identifier": "__PROJECT_REF__", + "identifier": "replayfixtureprojref", "database_type": "PRIMARY", - "db_user": "postgres.__PROJECT_REF__", + "db_user": "postgres.replayfixtureprojref", "db_host": "aws-0-us-east-1.pooler.supabase.green", "db_port": 6543, "db_name": "postgres", - "connection_string": "postgresql://postgres.__PROJECT_REF__:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.green:6543/postgres", + "connection_string": "postgresql://postgres.replayfixtureprojref:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.green:6543/postgres", "pool_mode": "transaction", "default_pool_size": null, "max_client_conn": null, "is_using_scram_auth": true, - "connectionString": "postgresql://postgres.__PROJECT_REF__:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.green:6543/postgres" + "connectionString": "postgresql://postgres.replayfixtureprojref:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.green:6543/postgres" } ] } diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/2.request.json similarity index 75% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/2.request.json index 5f28eff1d9..bcc36e7d26 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/3.request.json similarity index 75% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/3.request.json index 5f28eff1d9..bcc36e7d26 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/default.request.json new file mode 100644 index 0000000000..3bbe24a8a8 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_database_postgres/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_database_postgres/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/2.request.json similarity index 78% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/2.request.json index fb1539a87b..c3ae1d41bd 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/postgrest", + "path": "/v1/projects/replayfixtureprojref/config/storage", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/3.request.json similarity index 78% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/3.request.json index 28d7b8cf12..c3ae1d41bd 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/ssl-enforcement", + "path": "/v1/projects/replayfixtureprojref/config/storage", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/default.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/default.request.json index c60f7965d4..0eb54f142f 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_organizations___PROJECT_REF___entitlements/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/organizations/__PROJECT_REF__/entitlements", + "path": "/v1/projects/replayfixtureprojref/config/storage", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_config_storage/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___custom_hostname/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_custom_hostname/default.request.json similarity index 78% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___custom_hostname/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_custom_hostname/default.request.json index 7b19d0d356..b334b91cdd 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___custom_hostname/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_custom_hostname/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname", + "path": "/v1/projects/replayfixtureprojref/custom-hostname", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___custom_hostname/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_custom_hostname/default.response.json similarity index 91% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___custom_hostname/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_custom_hostname/default.response.json index d8b34de963..796753b938 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___custom_hostname/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_custom_hostname/default.response.json @@ -13,7 +13,7 @@ "errors": [], "messages": [], "result": { - "custom_origin_server": "__PROJECT_REF__.supabase.red", + "custom_origin_server": "replayfixtureprojref.supabase.red", "hostname": "www.urgsimurksi.xyz", "id": "00000000-0000-0000-0000-000000000000", "ownership_verification": { diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_database_backups/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_database_backups/default.request.json new file mode 100644 index 0000000000..39d7a2a801 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_database_backups/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/database/backups", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___database_backups/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_database_backups/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___database_backups/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_database_backups/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/2.request.json new file mode 100644 index 0000000000..dfdd712a97 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/2.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/functions", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/3.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/3.request.json index f37a9dc97c..dfdd712a97 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/functions", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/default.request.json new file mode 100644 index 0000000000..dfdd712a97 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/functions", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___functions/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_functions/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/2.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/2.request.json index 0377f2b154..01c1a271a3 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers", + "path": "/v1/projects/replayfixtureprojref/network-restrictions", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/3.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/3.request.json index 0377f2b154..01c1a271a3 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers", + "path": "/v1/projects/replayfixtureprojref/network-restrictions", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/4.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/4.request.json similarity index 76% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/4.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/4.request.json index 28d7b8cf12..0a63baf691 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/4.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/4.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/ssl-enforcement", + "path": "/v1/projects/replayfixtureprojref/network-restrictions", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/4.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/4.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/4.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/4.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/5.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/5.request.json similarity index 76% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/5.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/5.request.json index 1967a5e75d..0a63baf691 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/5.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/5.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions", + "path": "/v1/projects/replayfixtureprojref/network-restrictions", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/5.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/5.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/5.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/5.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/default.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/default.request.json index 0377f2b154..01c1a271a3 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth_sso_providers/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers", + "path": "/v1/projects/replayfixtureprojref/network-restrictions", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_network_restrictions/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_pgsodium/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_pgsodium/default.request.json new file mode 100644 index 0000000000..f888005edf --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_pgsodium/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/pgsodium", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___pgsodium/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_pgsodium/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___pgsodium/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_pgsodium/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___billing_addons/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/2.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___billing_addons/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/2.request.json index ad666f2604..9e8b4fa65c 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___billing_addons/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/billing/addons", + "path": "/v1/projects/replayfixtureprojref/postgrest", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/3.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/3.request.json index a53b9601cf..9e8b4fa65c 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_storage/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/storage", + "path": "/v1/projects/replayfixtureprojref/postgrest", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/default.request.json new file mode 100644 index 0000000000..763abd0287 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/postgrest", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___postgrest/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_postgrest/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/2.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/2.request.json index d0df1be1a8..11889a39ed 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/api-keys", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/3.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/3.request.json index d0df1be1a8..11889a39ed 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___api_keys/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/api-keys", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/default.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/default.request.json index ffc9c92d30..11889a39ed 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___secrets/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_secrets/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_performance/2.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/2.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_performance/2.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/2.request.json index 6e7314c9b8..36609c7cd6 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_performance/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/2.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/advisors/performance", + "path": "/v1/projects/replayfixtureprojref/ssl-enforcement", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/2.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/2.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/3.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/3.request.json similarity index 78% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/3.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/3.request.json index 34474f2c64..aa3fcd1571 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___config_auth/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/3.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth", + "path": "/v1/projects/replayfixtureprojref/ssl-enforcement", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/3.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/3.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/4.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/4.request.json similarity index 78% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/4.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/4.request.json index 1967a5e75d..aa3fcd1571 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___network_restrictions/4.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/4.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions", + "path": "/v1/projects/replayfixtureprojref/ssl-enforcement", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/4.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/4.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/4.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/4.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_performance/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/default.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_performance/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/default.request.json index 6e7314c9b8..36609c7cd6 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___advisors_performance/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/advisors/performance", + "path": "/v1/projects/replayfixtureprojref/ssl-enforcement", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___ssl_enforcement/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_ssl_enforcement/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___types_typescript/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_types_typescript/default.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___types_typescript/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_types_typescript/default.request.json index d6130d2ae4..3734320ac5 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___types_typescript/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_types_typescript/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/types/typescript", + "path": "/v1/projects/replayfixtureprojref/types/typescript", "query": { "included_schemas": "public" }, diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___types_typescript/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_types_typescript/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___types_typescript/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_types_typescript/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_vanity_subdomain/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_vanity_subdomain/default.request.json new file mode 100644 index 0000000000..2cc935d284 --- /dev/null +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_vanity_subdomain/default.request.json @@ -0,0 +1,12 @@ +{ + "method": "GET", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain", + "query": {}, + "headers": { + "accept-encoding": "gzip", + "authorization": "Bearer __ACCESS_TOKEN__", + "host": "localhost:__PORT__", + "user-agent": "SupabaseCLI/" + }, + "body": null +} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___vanity_subdomain/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_vanity_subdomain/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___vanity_subdomain/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v1_projects_replayfixtureprojref_vanity_subdomain/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_snippets/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v1_snippets/default.request.json index 5c0416736f..94a7c4a640 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_snippets/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v1_snippets/default.request.json @@ -2,7 +2,7 @@ "method": "GET", "path": "/v1/snippets", "query": { - "project_ref": "__PROJECT_REF__" + "project_ref": "replayfixtureprojref" }, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v2_projects___PROJECT_REF___config/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v2_projects___PROJECT_REF___config/default.request.json deleted file mode 100644 index 92189eef6e..0000000000 --- a/apps/cli-e2e/fixtures/recorded/GET_v2_projects___PROJECT_REF___config/default.request.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "method": "GET", - "path": "/v2/projects/__PROJECT_REF__/config", - "query": {}, - "headers": { - "accept-encoding": "gzip", - "authorization": "Bearer __ACCESS_TOKEN__", - "host": "localhost:__PORT__", - "user-agent": "SupabaseCLI/" - }, - "body": null -} diff --git a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches/default.request.json b/apps/cli-e2e/fixtures/recorded/GET_v2_projects_replayfixtureprojref_config/default.request.json similarity index 80% rename from apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches/default.request.json rename to apps/cli-e2e/fixtures/recorded/GET_v2_projects_replayfixtureprojref_config/default.request.json index 90e1a616cb..1fd2c6df3f 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v1_projects___PROJECT_REF___branches/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v2_projects_replayfixtureprojref_config/default.request.json @@ -1,6 +1,6 @@ { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches", + "path": "/v2/projects/replayfixtureprojref/config", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/GET_v2_projects___PROJECT_REF___config/default.response.json b/apps/cli-e2e/fixtures/recorded/GET_v2_projects_replayfixtureprojref_config/default.response.json similarity index 99% rename from apps/cli-e2e/fixtures/recorded/GET_v2_projects___PROJECT_REF___config/default.response.json rename to apps/cli-e2e/fixtures/recorded/GET_v2_projects_replayfixtureprojref_config/default.response.json index 32f2d66cda..3bddcd789d 100644 --- a/apps/cli-e2e/fixtures/recorded/GET_v2_projects___PROJECT_REF___config/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/GET_v2_projects_replayfixtureprojref_config/default.response.json @@ -10,7 +10,7 @@ "body": { "data": { "type": "project_config", - "id": "__PROJECT_REF__", + "id": "replayfixtureprojref", "attributes": { "database": { "major_version": 15, diff --git a/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___config_auth/default.request.json b/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_config_auth/default.request.json similarity index 86% rename from apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___config_auth/default.request.json rename to apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_config_auth/default.request.json index 8ebc770e78..01e5bd27dd 100644 --- a/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___config_auth/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_config_auth/default.request.json @@ -1,6 +1,6 @@ { "method": "PATCH", - "path": "/v1/projects/__PROJECT_REF__/config/auth", + "path": "/v1/projects/replayfixtureprojref/config/auth", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___config_auth/default.response.json b/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_config_auth/default.response.json similarity index 99% rename from apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___config_auth/default.response.json rename to apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_config_auth/default.response.json index 63f4b7dc8d..402a0c2d5b 100644 --- a/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___config_auth/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_config_auth/default.response.json @@ -40,6 +40,7 @@ "hook_before_user_created_secrets": null, "nimbus_oauth_client_id": null, "nimbus_oauth_client_secret": null, + "nimbus_oauth_email_optional": null, "external_anonymous_users_enabled": false, "external_apple_additional_client_ids": null, "external_apple_client_id": null, diff --git a/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___network_restrictions/default.request.json b/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_network_restrictions/default.request.json similarity index 84% rename from apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___network_restrictions/default.request.json rename to apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_network_restrictions/default.request.json index b1d6463463..b9ec02a375 100644 --- a/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___network_restrictions/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_network_restrictions/default.request.json @@ -1,6 +1,6 @@ { "method": "PATCH", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions", + "path": "/v1/projects/replayfixtureprojref/network-restrictions", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___network_restrictions/default.response.json b/apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_network_restrictions/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/PATCH_v1_projects___PROJECT_REF___network_restrictions/default.response.json rename to apps/cli-e2e/fixtures/recorded/PATCH_v1_projects_replayfixtureprojref_network_restrictions/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_bucket/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_bucket/default.response.json index 9bf59f4e78..432b92f55b 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_bucket/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_bucket/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_cached_txt/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_cached_txt/default.response.json index 1279a44a56..fddaa17bee 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_cached_txt/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_cached_txt/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_mv_source_txt/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_mv_source_txt/default.response.json index 76e3cededd..92061e714b 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_mv_source_txt/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_mv_source_txt/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_a_txt/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_a_txt/default.response.json index 242bf593da..6f62d80f96 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_a_txt/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_a_txt/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_b_txt/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_b_txt/default.response.json index f24511c4ec..ad416ae65a 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_b_txt/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_b_txt/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_target_txt/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_target_txt/default.response.json index ca29aa9df7..7a4788e754 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_target_txt/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_rm_target_txt/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_typed_txt/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_typed_txt/default.response.json index 64a5469fab..89f4f33a19 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_typed_txt/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_typed_txt/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_upload_txt/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_upload_txt/default.response.json index c593b75784..1b0f72e07a 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_upload_txt/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_cli_e2e_bucket_upload_txt/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_list_cli_e2e_bucket/2.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_list_cli_e2e_bucket/2.response.json index 8f23f442a8..2267284f45 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_list_cli_e2e_bucket/2.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_list_cli_e2e_bucket/2.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_list_cli_e2e_bucket/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_list_cli_e2e_bucket/default.response.json index 8f23f442a8..2267284f45 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_list_cli_e2e_bucket/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_list_cli_e2e_bucket/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_move/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_move/default.response.json index 287142abae..5b96815328 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_move/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_storage_v1_object_move/default.response.json @@ -5,7 +5,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_organizations/2.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_organizations/2.response.json index ef71d3d4a3..a3226e0bed 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_organizations/2.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_organizations/2.response.json @@ -4,8 +4,8 @@ "content-type": "application/json; charset=utf-8" }, "body": { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "Test Org" } } diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_organizations/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_organizations/default.response.json index ef71d3d4a3..a3226e0bed 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_organizations/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_organizations/default.response.json @@ -4,8 +4,8 @@ "content-type": "application/json; charset=utf-8" }, "body": { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "Test Org" } } diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects/2.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects/2.request.json index a7217c7a63..f14b19f76c 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects/2.request.json @@ -14,7 +14,7 @@ "db_pass": "password123", "desired_instance_size": "micro", "name": "my-project", - "organization_slug": "__PROJECT_REF__", + "organization_slug": "replayfixtureprojref", "region": "us-east-1" } } diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects/2.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects/2.response.json index 3a91da27ab..e6ddc2538a 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects/2.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects/2.response.json @@ -8,10 +8,10 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "my-project", "region": "us-east-1", "created_at": "2000-01-01T00:00:00Z", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects/default.request.json index 855525e4e8..52689d52c7 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects/default.request.json @@ -13,7 +13,7 @@ "body": { "db_pass": "cli-e2e-password-123", "name": "cli-e2e-test", - "organization_slug": "__PROJECT_REF__", + "organization_slug": "replayfixtureprojref", "region": "us-east-1" } } diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects/default.response.json index 0c132719e4..8f6668f291 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects/default.response.json @@ -8,10 +8,10 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "created_at": "2000-01-01T00:00:00Z", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___branches/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_branches/default.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___branches/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_branches/default.request.json index 5c7a1c1d49..077d425cd2 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___branches/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_branches/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/branches", + "path": "/v1/projects/replayfixtureprojref/branches", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___branches/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_branches/default.response.json similarity index 92% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___branches/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_branches/default.response.json index 990c8311c1..848c25491f 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___branches/default.response.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_branches/default.response.json @@ -11,7 +11,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___config_auth_sso_providers/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.request.json similarity index 91% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___config_auth_sso_providers/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.request.json index acbd431466..579e0fa208 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___config_auth_sso_providers/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___config_auth_sso_providers/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___config_auth_sso_providers/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_config_auth_sso_providers/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___custom_hostname_activate/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_custom_hostname_activate/default.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___custom_hostname_activate/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_custom_hostname_activate/default.request.json index a9eef9b8a3..8cfca20bf2 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___custom_hostname_activate/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_custom_hostname_activate/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/activate", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/activate", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___custom_hostname_activate/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_custom_hostname_activate/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___custom_hostname_activate/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_custom_hostname_activate/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___custom_hostname_reverify/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_custom_hostname_reverify/default.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___custom_hostname_reverify/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_custom_hostname_reverify/default.request.json index 35af27f8dc..c5f8e259db 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___custom_hostname_reverify/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_custom_hostname_reverify/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/reverify", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/reverify", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___custom_hostname_reverify/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_custom_hostname_reverify/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___custom_hostname_reverify/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_custom_hostname_reverify/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_backups_restore_pitr/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_backups_restore_pitr/default.request.json similarity index 81% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_backups_restore_pitr/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_backups_restore_pitr/default.request.json index d3070b2b28..7b0a23b9ae 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_backups_restore_pitr/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_backups_restore_pitr/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/database/backups/restore-pitr", + "path": "/v1/projects/replayfixtureprojref/database/backups/restore-pitr", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_backups_restore_pitr/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_backups_restore_pitr/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_backups_restore_pitr/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_backups_restore_pitr/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_query/2.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_query/2.request.json similarity index 83% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_query/2.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_query/2.request.json index 3454252ccf..0ee6600339 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_query/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_query/2.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/database/query", + "path": "/v1/projects/replayfixtureprojref/database/query", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_query/2.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_query/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_query/2.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_query/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_query/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_query/default.request.json similarity index 83% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_query/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_query/default.request.json index 3454252ccf..0ee6600339 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_query/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_query/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/database/query", + "path": "/v1/projects/replayfixtureprojref/database/query", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_query/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_query/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_query/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_query/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_webhooks_enable/2.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_webhooks_enable/2.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_webhooks_enable/2.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_webhooks_enable/2.request.json index d717912fd9..db5d564a4b 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_webhooks_enable/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_webhooks_enable/2.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/database/webhooks/enable", + "path": "/v1/projects/replayfixtureprojref/database/webhooks/enable", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_webhooks_enable/2.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_webhooks_enable/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_webhooks_enable/2.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_webhooks_enable/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_webhooks_enable/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_webhooks_enable/default.request.json similarity index 77% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_webhooks_enable/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_webhooks_enable/default.request.json index d717912fd9..db5d564a4b 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_webhooks_enable/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_webhooks_enable/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/database/webhooks/enable", + "path": "/v1/projects/replayfixtureprojref/database/webhooks/enable", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_webhooks_enable/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_webhooks_enable/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___database_webhooks_enable/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_database_webhooks_enable/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/2.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/2.request.json similarity index 78% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/2.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/2.request.json index 344e0332a5..add8a6037a 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/2.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/network-bans/retrieve", + "path": "/v1/projects/replayfixtureprojref/network-bans/retrieve", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/2.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/2.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/3.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/3.request.json similarity index 78% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/3.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/3.request.json index 344e0332a5..add8a6037a 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/3.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/network-bans/retrieve", + "path": "/v1/projects/replayfixtureprojref/network-bans/retrieve", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/3.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/3.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/default.request.json similarity index 78% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/default.request.json index 344e0332a5..add8a6037a 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/network-bans/retrieve", + "path": "/v1/projects/replayfixtureprojref/network-bans/retrieve", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_bans_retrieve/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_bans_retrieve/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_restrictions_apply/2.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_restrictions_apply/2.request.json similarity index 82% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_restrictions_apply/2.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_restrictions_apply/2.request.json index a51f304ada..60e3c04faf 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_restrictions_apply/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_restrictions_apply/2.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions/apply", + "path": "/v1/projects/replayfixtureprojref/network-restrictions/apply", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_restrictions_apply/2.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_restrictions_apply/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_restrictions_apply/2.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_restrictions_apply/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_restrictions_apply/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_restrictions_apply/default.request.json similarity index 82% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_restrictions_apply/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_restrictions_apply/default.request.json index a51f304ada..60e3c04faf 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_restrictions_apply/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_restrictions_apply/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions/apply", + "path": "/v1/projects/replayfixtureprojref/network-restrictions/apply", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_restrictions_apply/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_restrictions_apply/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___network_restrictions_apply/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_network_restrictions_apply/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/2.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/2.request.json similarity index 87% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/2.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/2.request.json index 130d9ea18f..7ca6da58e7 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/2.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/2.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/2.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/3.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/3.request.json similarity index 86% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/3.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/3.request.json index 9ee9df174b..6cad61e34b 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/3.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/3.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/3.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/3.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/3.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/3.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/4.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/4.request.json similarity index 87% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/4.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/4.request.json index 641a391fa8..5505f2dff0 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/4.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/4.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/4.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/4.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/4.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/4.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/default.request.json similarity index 86% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/default.request.json index 9ee9df174b..6cad61e34b 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___secrets/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_secrets/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___vanity_subdomain_activate/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_vanity_subdomain_activate/default.request.json similarity index 81% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___vanity_subdomain_activate/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_vanity_subdomain_activate/default.request.json index 6716dd9f58..6d29cb267c 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___vanity_subdomain_activate/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_vanity_subdomain_activate/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain/activate", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain/activate", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___vanity_subdomain_activate/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_vanity_subdomain_activate/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___vanity_subdomain_activate/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_vanity_subdomain_activate/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___vanity_subdomain_check_availability/default.request.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_vanity_subdomain_check_availability/default.request.json similarity index 79% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___vanity_subdomain_check_availability/default.request.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_vanity_subdomain_check_availability/default.request.json index a03f31e848..a246af8048 100644 --- a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___vanity_subdomain_check_availability/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_vanity_subdomain_check_availability/default.request.json @@ -1,6 +1,6 @@ { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain/check-availability", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain/check-availability", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___vanity_subdomain_check_availability/default.response.json b/apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_vanity_subdomain_check_availability/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/POST_v1_projects___PROJECT_REF___vanity_subdomain_check_availability/default.response.json rename to apps/cli-e2e/fixtures/recorded/POST_v1_projects_replayfixtureprojref_vanity_subdomain_check_availability/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___config_database_postgres/default.request.json b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_config_database_postgres/default.request.json similarity index 85% rename from apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___config_database_postgres/default.request.json rename to apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_config_database_postgres/default.request.json index cecea59539..e88bf17818 100644 --- a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___config_database_postgres/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_config_database_postgres/default.request.json @@ -1,6 +1,6 @@ { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___config_database_postgres/default.response.json b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_config_database_postgres/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___config_database_postgres/default.response.json rename to apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_config_database_postgres/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___pgsodium/default.request.json b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_pgsodium/default.request.json similarity index 84% rename from apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___pgsodium/default.request.json rename to apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_pgsodium/default.request.json index 856d5f59dd..9b04cd8096 100644 --- a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___pgsodium/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_pgsodium/default.request.json @@ -1,6 +1,6 @@ { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/pgsodium", + "path": "/v1/projects/replayfixtureprojref/pgsodium", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___pgsodium/default.response.json b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_pgsodium/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___pgsodium/default.response.json rename to apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_pgsodium/default.response.json diff --git a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___ssl_enforcement/2.request.json b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_ssl_enforcement/2.request.json similarity index 84% rename from apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___ssl_enforcement/2.request.json rename to apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_ssl_enforcement/2.request.json index bf5be40386..0e8caa071c 100644 --- a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___ssl_enforcement/2.request.json +++ b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_ssl_enforcement/2.request.json @@ -1,6 +1,6 @@ { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/ssl-enforcement", + "path": "/v1/projects/replayfixtureprojref/ssl-enforcement", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___ssl_enforcement/2.response.json b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_ssl_enforcement/2.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___ssl_enforcement/2.response.json rename to apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_ssl_enforcement/2.response.json diff --git a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___ssl_enforcement/default.request.json b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_ssl_enforcement/default.request.json similarity index 84% rename from apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___ssl_enforcement/default.request.json rename to apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_ssl_enforcement/default.request.json index 9e6c154878..eccda0b75f 100644 --- a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___ssl_enforcement/default.request.json +++ b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_ssl_enforcement/default.request.json @@ -1,6 +1,6 @@ { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/ssl-enforcement", + "path": "/v1/projects/replayfixtureprojref/ssl-enforcement", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___ssl_enforcement/default.response.json b/apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_ssl_enforcement/default.response.json similarity index 100% rename from apps/cli-e2e/fixtures/recorded/PUT_v1_projects___PROJECT_REF___ssl_enforcement/default.response.json rename to apps/cli-e2e/fixtures/recorded/PUT_v1_projects_replayfixtureprojref_ssl_enforcement/default.response.json diff --git a/apps/cli-e2e/fixtures/scenarios/backups-list-debug-shows-http-trace/interactions.json b/apps/cli-e2e/fixtures/scenarios/backups-list-debug-shows-http-trace/interactions.json index 360ca3a072..e5147cf38b 100644 --- a/apps/cli-e2e/fixtures/scenarios/backups-list-debug-shows-http-trace/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/backups-list-debug-shows-http-trace/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/database/backups", + "path": "/v1/projects/replayfixtureprojref/database/backups", "query": {}, "headers": { "accept-encoding": "gzip", @@ -33,7 +33,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -53,15 +53,15 @@ "x-ratelimit-reset": "7" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/backups-list-renders-backup-table-with-region-column/interactions.json b/apps/cli-e2e/fixtures/scenarios/backups-list-renders-backup-table-with-region-column/interactions.json index 9bbd6bee94..4182b529ed 100644 --- a/apps/cli-e2e/fixtures/scenarios/backups-list-renders-backup-table-with-region-column/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/backups-list-renders-backup-table-with-region-column/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/database/backups", + "path": "/v1/projects/replayfixtureprojref/database/backups", "query": {}, "headers": { "accept-encoding": "gzip", @@ -33,7 +33,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -53,15 +53,15 @@ "x-ratelimit-reset": "9" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/backups-list-returns-backup-response-as-json-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/backups-list-returns-backup-response-as-json-with-output-json/interactions.json index 76639b1437..e537d665d9 100644 --- a/apps/cli-e2e/fixtures/scenarios/backups-list-returns-backup-response-as-json-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/backups-list-returns-backup-response-as-json-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/database/backups", + "path": "/v1/projects/replayfixtureprojref/database/backups", "query": {}, "headers": { "accept-encoding": "gzip", @@ -33,7 +33,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -53,15 +53,15 @@ "x-ratelimit-reset": "8" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/backups-restore-initiates-pitr-restore-with-t-timestamp/interactions.json b/apps/cli-e2e/fixtures/scenarios/backups-restore-initiates-pitr-restore-with-t-timestamp/interactions.json index 3af3c58289..b0545b4247 100644 --- a/apps/cli-e2e/fixtures/scenarios/backups-restore-initiates-pitr-restore-with-t-timestamp/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/backups-restore-initiates-pitr-restore-with-t-timestamp/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/database/backups/restore-pitr", + "path": "/v1/projects/replayfixtureprojref/database/backups/restore-pitr", "query": {}, "headers": { "accept-encoding": "gzip", @@ -31,7 +31,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -51,15 +51,15 @@ "x-ratelimit-reset": "51" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/branches-create-creates-branch-with-data/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-create-creates-branch-with-data/interactions.json index 9d25a7819b..62dc4ef547 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-create-creates-branch-with-data/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-create-creates-branch-with-data/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/branches", + "path": "/v1/projects/replayfixtureprojref/branches", "query": {}, "headers": { "accept-encoding": "gzip", @@ -25,7 +25,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, diff --git a/apps/cli-e2e/fixtures/scenarios/branches-create-creates-ephemeral-branch/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-create-creates-ephemeral-branch/interactions.json index 9d25a7819b..62dc4ef547 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-create-creates-ephemeral-branch/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-create-creates-ephemeral-branch/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/branches", + "path": "/v1/projects/replayfixtureprojref/branches", "query": {}, "headers": { "accept-encoding": "gzip", @@ -25,7 +25,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, diff --git a/apps/cli-e2e/fixtures/scenarios/branches-create-creates-persistent-branch/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-create-creates-persistent-branch/interactions.json index 9d25a7819b..62dc4ef547 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-create-creates-persistent-branch/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-create-creates-persistent-branch/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/branches", + "path": "/v1/projects/replayfixtureprojref/branches", "query": {}, "headers": { "accept-encoding": "gzip", @@ -25,7 +25,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, diff --git a/apps/cli-e2e/fixtures/scenarios/branches-delete-deletes-branch-successfully/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-delete-deletes-branch-successfully/interactions.json index 871a962780..dd3c829210 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-delete-deletes-branch-successfully/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-delete-deletes-branch-successfully/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches/my-branch", + "path": "/v1/projects/replayfixtureprojref/branches/my-branch", "query": {}, "headers": { "accept-encoding": "gzip", @@ -25,7 +25,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, @@ -39,7 +39,7 @@ { "request": { "method": "DELETE", - "path": "/v1/branches/__PROJECT_REF__", + "path": "/v1/branches/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/branches-disable-disables-preview-branching/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-disable-disables-preview-branching/interactions.json index 1e505fa72f..d37146b00d 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-disable-disables-preview-branching/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-disable-disables-preview-branching/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/branches", + "path": "/v1/projects/replayfixtureprojref/branches", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/branches-get-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-get-returns-json-output-with-output-json/interactions.json index 7498935db3..6d6e83ea58 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-get-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-get-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches/my-branch", + "path": "/v1/projects/replayfixtureprojref/branches/my-branch", "query": {}, "headers": { "accept-encoding": "gzip", @@ -25,7 +25,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, @@ -39,7 +39,7 @@ { "request": { "method": "GET", - "path": "/v1/branches/__PROJECT_REF__", + "path": "/v1/branches/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -75,7 +75,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/api-keys", + "path": "/v1/projects/replayfixtureprojref/api-keys", "query": {}, "headers": { "accept-encoding": "gzip", @@ -103,7 +103,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/pooler", + "path": "/v1/projects/replayfixtureprojref/config/database/pooler", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/branches-get-returns-single-branch-details/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-get-returns-single-branch-details/interactions.json index 7c5439bc46..ea9bb84c5a 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-get-returns-single-branch-details/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-get-returns-single-branch-details/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches/my-branch", + "path": "/v1/projects/replayfixtureprojref/branches/my-branch", "query": {}, "headers": { "accept-encoding": "gzip", @@ -25,7 +25,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, @@ -39,7 +39,7 @@ { "request": { "method": "GET", - "path": "/v1/branches/__PROJECT_REF__", + "path": "/v1/branches/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/branches-list-includes-debug-output-with-debug/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-list-includes-debug-output-with-debug/interactions.json index 0cd93eb081..05c9ecbf07 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-list-includes-debug-output-with-debug/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-list-includes-debug-output-with-debug/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches", + "path": "/v1/projects/replayfixtureprojref/branches", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "58" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/branches-list-renders-fixture-data-in-output/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-list-renders-fixture-data-in-output/interactions.json index 28ff6c9a6d..7f9fdccaa9 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-list-renders-fixture-data-in-output/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-list-renders-fixture-data-in-output/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches", + "path": "/v1/projects/replayfixtureprojref/branches", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "59" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/branches-list-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-list-returns-json-output-with-output-json/interactions.json index 7473a87935..c9fe7ed496 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-list-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-list-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches", + "path": "/v1/projects/replayfixtureprojref/branches", "query": {}, "headers": { "accept-encoding": "gzip", @@ -26,7 +26,7 @@ "id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee", "name": "test-branch", "project_ref": "branchrefaaaaaaaaa", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "persistent": false, "status": "MIGRATIONS_PASSED", diff --git a/apps/cli-e2e/fixtures/scenarios/branches-pause-pauses-branch-successfully/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-pause-pauses-branch-successfully/interactions.json index b5e18d2b8f..44802e0818 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-pause-pauses-branch-successfully/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-pause-pauses-branch-successfully/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches/my-branch", + "path": "/v1/projects/replayfixtureprojref/branches/my-branch", "query": {}, "headers": { "accept-encoding": "gzip", @@ -25,7 +25,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, @@ -39,7 +39,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/pause", + "path": "/v1/projects/replayfixtureprojref/pause", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/branches-unpause-unpauses-branch-successfully/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-unpause-unpauses-branch-successfully/interactions.json index 643c6faf30..d964ef6a8b 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-unpause-unpauses-branch-successfully/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-unpause-unpauses-branch-successfully/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches/my-branch", + "path": "/v1/projects/replayfixtureprojref/branches/my-branch", "query": {}, "headers": { "accept-encoding": "gzip", @@ -25,7 +25,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, @@ -39,7 +39,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/restore", + "path": "/v1/projects/replayfixtureprojref/restore", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/branches-update-changes-git-branch-with-git-branch/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-update-changes-git-branch-with-git-branch/interactions.json index fcfc83154e..09583c5f6c 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-update-changes-git-branch-with-git-branch/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-update-changes-git-branch-with-git-branch/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches/my-branch", + "path": "/v1/projects/replayfixtureprojref/branches/my-branch", "query": {}, "headers": { "accept-encoding": "gzip", @@ -25,7 +25,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, @@ -39,7 +39,7 @@ { "request": { "method": "PATCH", - "path": "/v1/branches/__PROJECT_REF__", + "path": "/v1/branches/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -62,7 +62,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, diff --git a/apps/cli-e2e/fixtures/scenarios/branches-update-renames-branch-with-name/interactions.json b/apps/cli-e2e/fixtures/scenarios/branches-update-renames-branch-with-name/interactions.json index fcfc83154e..09583c5f6c 100644 --- a/apps/cli-e2e/fixtures/scenarios/branches-update-renames-branch-with-name/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/branches-update-renames-branch-with-name/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/branches/my-branch", + "path": "/v1/projects/replayfixtureprojref/branches/my-branch", "query": {}, "headers": { "accept-encoding": "gzip", @@ -25,7 +25,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, @@ -39,7 +39,7 @@ { "request": { "method": "PATCH", - "path": "/v1/branches/__PROJECT_REF__", + "path": "/v1/branches/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -62,7 +62,7 @@ "id": "00000000-0000-0000-0000-000000000000", "name": "my-branch", "project_ref": "bbbbbbbbbbbbbbbbbbbb", - "parent_project_ref": "__PROJECT_REF__", + "parent_project_ref": "replayfixtureprojref", "is_default": false, "git_branch": "main", "persistent": false, diff --git a/apps/cli-e2e/fixtures/scenarios/config-push-emits-http-trace-with-debug/interactions.json b/apps/cli-e2e/fixtures/scenarios/config-push-emits-http-trace-with-debug/interactions.json index 686b20071a..3592f95830 100644 --- a/apps/cli-e2e/fixtures/scenarios/config-push-emits-http-trace-with-debug/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/config-push-emits-http-trace-with-debug/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/billing/addons", + "path": "/v1/projects/replayfixtureprojref/billing/addons", "query": {}, "headers": { "accept-encoding": "gzip", @@ -745,7 +745,7 @@ { "request": { "method": "GET", - "path": "/v2/projects/__PROJECT_REF__/config", + "path": "/v2/projects/replayfixtureprojref/config", "query": {}, "headers": { "accept-encoding": "gzip", @@ -767,7 +767,7 @@ "body": { "data": { "type": "project_config", - "id": "__PROJECT_REF__", + "id": "replayfixtureprojref", "attributes": { "database": { "major_version": 15, diff --git a/apps/cli-e2e/fixtures/scenarios/config-push-reconciles-every-section-against-the-remote/interactions.json b/apps/cli-e2e/fixtures/scenarios/config-push-reconciles-every-section-against-the-remote/interactions.json index e7b18825fa..08cd367f99 100644 --- a/apps/cli-e2e/fixtures/scenarios/config-push-reconciles-every-section-against-the-remote/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/config-push-reconciles-every-section-against-the-remote/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/billing/addons", + "path": "/v1/projects/replayfixtureprojref/billing/addons", "query": {}, "headers": { "accept-encoding": "gzip", @@ -745,7 +745,7 @@ { "request": { "method": "GET", - "path": "/v2/projects/__PROJECT_REF__/config", + "path": "/v2/projects/replayfixtureprojref/config", "query": {}, "headers": { "accept-encoding": "gzip", @@ -767,7 +767,7 @@ "body": { "data": { "type": "project_config", - "id": "__PROJECT_REF__", + "id": "replayfixtureprojref", "attributes": { "database": { "major_version": 15, diff --git a/apps/cli-e2e/fixtures/scenarios/db-advisors-all-returns-advisors-with-type-all/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-advisors-all-returns-advisors-with-type-all/interactions.json index 58eb2e731d..aeb0278cbf 100644 --- a/apps/cli-e2e/fixtures/scenarios/db-advisors-all-returns-advisors-with-type-all/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/db-advisors-all-returns-advisors-with-type-all/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/advisors/security", + "path": "/v1/projects/replayfixtureprojref/advisors/security", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/advisors/performance", + "path": "/v1/projects/replayfixtureprojref/advisors/performance", "query": {}, "headers": { "accept-encoding": "gzip", @@ -72,7 +72,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -92,15 +92,15 @@ "x-ratelimit-reset": "39" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/db-advisors-performance-returns-performance-advisors/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-advisors-performance-returns-performance-advisors/interactions.json index 368bc1d273..d5412f7e68 100644 --- a/apps/cli-e2e/fixtures/scenarios/db-advisors-performance-returns-performance-advisors/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/db-advisors-performance-returns-performance-advisors/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/advisors/performance", + "path": "/v1/projects/replayfixtureprojref/advisors/performance", "query": {}, "headers": { "accept-encoding": "gzip", @@ -45,7 +45,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -65,15 +65,15 @@ "x-ratelimit-reset": "46" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/db-advisors-security-exits-zero-when-fail-on-error-and-no-error-level-advisors-found/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-advisors-security-exits-zero-when-fail-on-error-and-no-error-level-advisors-found/interactions.json index 618219cbdb..ed58859a34 100644 --- a/apps/cli-e2e/fixtures/scenarios/db-advisors-security-exits-zero-when-fail-on-error-and-no-error-level-advisors-found/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/db-advisors-security-exits-zero-when-fail-on-error-and-no-error-level-advisors-found/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/advisors/security", + "path": "/v1/projects/replayfixtureprojref/advisors/security", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "50" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/db-advisors-security-returns-security-advisors/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-advisors-security-returns-security-advisors/interactions.json index 0a841590a9..0790d147ea 100644 --- a/apps/cli-e2e/fixtures/scenarios/db-advisors-security-returns-security-advisors/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/db-advisors-security-returns-security-advisors/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/advisors/security", + "path": "/v1/projects/replayfixtureprojref/advisors/security", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "54" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/db-push-dry-run-exits-non-zero-on-connection-refused-with-dry-run/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-push-dry-run-exits-non-zero-on-connection-refused-with-dry-run/interactions.json index 2889c5be55..4596be6911 100644 --- a/apps/cli-e2e/fixtures/scenarios/db-push-dry-run-exits-non-zero-on-connection-refused-with-dry-run/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/db-push-dry-run-exits-non-zero-on-connection-refused-with-dry-run/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -22,15 +22,15 @@ "x-ratelimit-reset": "31" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/db-push-linked-exits-non-zero-on-connection-refused-with-linked/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-push-linked-exits-non-zero-on-connection-refused-with-linked/interactions.json index d1412357ea..9b6c2a13e7 100644 --- a/apps/cli-e2e/fixtures/scenarios/db-push-linked-exits-non-zero-on-connection-refused-with-linked/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/db-push-linked-exits-non-zero-on-connection-refused-with-linked/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -22,15 +22,15 @@ "x-ratelimit-reset": "30" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-json-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-json-with-output-json/interactions.json index 4967bb9666..4ce1bb15f0 100644 --- a/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-json-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-json-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/database/query", + "path": "/v1/projects/replayfixtureprojref/database/query", "query": {}, "headers": { "accept-encoding": "gzip", @@ -36,7 +36,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -56,15 +56,15 @@ "x-ratelimit-reset": "34" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-select-1-result-in-table-format/interactions.json b/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-select-1-result-in-table-format/interactions.json index e1355cab3a..533edf3271 100644 --- a/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-select-1-result-in-table-format/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/db-query-linked-returns-select-1-result-in-table-format/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/database/query", + "path": "/v1/projects/replayfixtureprojref/database/query", "query": {}, "headers": { "accept-encoding": "gzip", @@ -36,7 +36,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -56,15 +56,15 @@ "x-ratelimit-reset": "36" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/domains-activate-custom-domain-disabled/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-activate-custom-domain-disabled/interactions.json index 02a9fe2987..cc2b4bca6c 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-activate-custom-domain-disabled/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-activate-custom-domain-disabled/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/activate", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/activate", "query": {}, "headers": { "accept-encoding": "gzip", @@ -30,7 +30,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-activate-domain-activated/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-activate-domain-activated/interactions.json index c41eb52db4..f07ccb4454 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-activate-domain-activated/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-activate-domain-activated/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/activate", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/activate", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-activate-no-custom-domain/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-activate-no-custom-domain/interactions.json index 354087c82e..18c0f0d7a2 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-activate-no-custom-domain/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-activate-no-custom-domain/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/activate", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/activate", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-activate-pending-verification-in-debug-mode/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-activate-pending-verification-in-debug-mode/interactions.json index 354087c82e..18c0f0d7a2 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-activate-pending-verification-in-debug-mode/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-activate-pending-verification-in-debug-mode/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/activate", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/activate", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-activate-pending-verification/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-activate-pending-verification/interactions.json index 354087c82e..18c0f0d7a2 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-activate-pending-verification/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-activate-pending-verification/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/activate", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/activate", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-activate-verification-completed/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-activate-verification-completed/interactions.json index d783527baf..338a6bea43 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-activate-verification-completed/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-activate-verification-completed/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/activate", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/activate", "query": {}, "headers": { "accept-encoding": "gzip", @@ -28,7 +28,7 @@ "errors": [], "messages": [], "result": { - "custom_origin_server": "__PROJECT_REF__.supabase.red", + "custom_origin_server": "replayfixtureprojref.supabase.red", "hostname": "www.urgsimurksi.xyz", "id": "00000000-0000-0000-0000-000000000000", "ownership_verification": { diff --git a/apps/cli-e2e/fixtures/scenarios/domains-create-custom-domain-disabled/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-create-custom-domain-disabled/interactions.json index 1393e7690d..06a13a31bb 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-create-custom-domain-disabled/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-create-custom-domain-disabled/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/initialize", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/initialize", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-create-no-custom-domain/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-create-no-custom-domain/interactions.json index c7bc63fd98..d0f5af268e 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-create-no-custom-domain/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-create-no-custom-domain/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/initialize", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/initialize", "query": {}, "headers": { "accept-encoding": "gzip", @@ -31,7 +31,7 @@ "errors": [], "messages": [], "result": { - "custom_origin_server": "__PROJECT_REF__.supabase.red", + "custom_origin_server": "replayfixtureprojref.supabase.red", "hostname": "www.urgsimurksi.xyz", "id": "00000000-0000-0000-0000-000000000000", "ownership_verification": { diff --git a/apps/cli-e2e/fixtures/scenarios/domains-create-pending-verification/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-create-pending-verification/interactions.json index 46cccbbcd8..5656b16f99 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-create-pending-verification/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-create-pending-verification/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/initialize", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/initialize", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-delete-custom-domain-disabled/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-delete-custom-domain-disabled/interactions.json index 6975d1015f..442f415664 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-delete-custom-domain-disabled/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-delete-custom-domain-disabled/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname", + "path": "/v1/projects/replayfixtureprojref/custom-hostname", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-delete-no-custom-domain/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-delete-no-custom-domain/interactions.json index e3df425d0d..1f199f9c41 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-delete-no-custom-domain/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-delete-no-custom-domain/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname", + "path": "/v1/projects/replayfixtureprojref/custom-hostname", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-delete-pending-verification/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-delete-pending-verification/interactions.json index b74f58cdc1..f5d1309b44 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-delete-pending-verification/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-delete-pending-verification/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname", + "path": "/v1/projects/replayfixtureprojref/custom-hostname", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-get-domain-activated/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-get-domain-activated/interactions.json index 79e938bff6..1ba4fcad8f 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-get-domain-activated/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-get-domain-activated/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname", + "path": "/v1/projects/replayfixtureprojref/custom-hostname", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ "errors": [], "messages": [], "result": { - "custom_origin_server": "__PROJECT_REF__.supabase.red", + "custom_origin_server": "replayfixtureprojref.supabase.red", "hostname": "www.urgsimurksi.xyz", "id": "00000000-0000-0000-0000-000000000000", "ownership_verification": { diff --git a/apps/cli-e2e/fixtures/scenarios/domains-get-no-custom-domain/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-get-no-custom-domain/interactions.json index b5bb43ac50..5ed8dc1793 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-get-no-custom-domain/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-get-no-custom-domain/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname", + "path": "/v1/projects/replayfixtureprojref/custom-hostname", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-get-pending-verification/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-get-pending-verification/interactions.json index 6e83f89315..626a8a57d7 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-get-pending-verification/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-get-pending-verification/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname", + "path": "/v1/projects/replayfixtureprojref/custom-hostname", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ "errors": [], "messages": [], "result": { - "custom_origin_server": "__PROJECT_REF__.supabase.red", + "custom_origin_server": "replayfixtureprojref.supabase.red", "hostname": "www.urgsimurksi.xyz", "id": "00000000-0000-0000-0000-000000000000", "ownership_verification": { diff --git a/apps/cli-e2e/fixtures/scenarios/domains-get-verification-completed/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-get-verification-completed/interactions.json index 7d6fc488c8..51f214ef65 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-get-verification-completed/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-get-verification-completed/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname", + "path": "/v1/projects/replayfixtureprojref/custom-hostname", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ "errors": [], "messages": [], "result": { - "custom_origin_server": "__PROJECT_REF__.supabase.red", + "custom_origin_server": "replayfixtureprojref.supabase.red", "hostname": "www.urgsimurksi.xyz", "id": "00000000-0000-0000-0000-000000000000", "ownership_verification": { diff --git a/apps/cli-e2e/fixtures/scenarios/domains-reverify-custom-domain-disabled/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-reverify-custom-domain-disabled/interactions.json index 55a2b46ff1..52a31c8eec 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-reverify-custom-domain-disabled/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-reverify-custom-domain-disabled/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/reverify", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/reverify", "query": {}, "headers": { "accept-encoding": "gzip", @@ -30,7 +30,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-reverify-no-custom-domain/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-reverify-no-custom-domain/interactions.json index 15f0188362..e917aea913 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-reverify-no-custom-domain/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-reverify-no-custom-domain/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/reverify", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/reverify", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/domains-reverify-pending-verification/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-reverify-pending-verification/interactions.json index d666d06ff4..142df388eb 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-reverify-pending-verification/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-reverify-pending-verification/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/reverify", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/reverify", "query": {}, "headers": { "accept-encoding": "gzip", @@ -28,7 +28,7 @@ "errors": [], "messages": [], "result": { - "custom_origin_server": "__PROJECT_REF__.supabase.red", + "custom_origin_server": "replayfixtureprojref.supabase.red", "hostname": "www.urgsimurksi.xyz", "id": "00000000-0000-0000-0000-000000000000", "ownership_verification": { diff --git a/apps/cli-e2e/fixtures/scenarios/domains-reverify-verification-completed/interactions.json b/apps/cli-e2e/fixtures/scenarios/domains-reverify-verification-completed/interactions.json index db385be37a..7b954b67e3 100644 --- a/apps/cli-e2e/fixtures/scenarios/domains-reverify-verification-completed/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/domains-reverify-verification-completed/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/custom-hostname/reverify", + "path": "/v1/projects/replayfixtureprojref/custom-hostname/reverify", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-debug-shows-http-trace/interactions.json b/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-debug-shows-http-trace/interactions.json index f7899f6aa1..15c42e3fa0 100644 --- a/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-debug-shows-http-trace/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-debug-shows-http-trace/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/pgsodium", + "path": "/v1/projects/replayfixtureprojref/pgsodium", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "11" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-renders-root-encryption-key/interactions.json b/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-renders-root-encryption-key/interactions.json index 331e7aaeb0..3be438193f 100644 --- a/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-renders-root-encryption-key/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-renders-root-encryption-key/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/pgsodium", + "path": "/v1/projects/replayfixtureprojref/pgsodium", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "14" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-returns-root-key-as-json-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-returns-root-key-as-json-with-output-json/interactions.json index f4f72607bf..47c57166e0 100644 --- a/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-returns-root-key-as-json-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/encryption-get-root-key-returns-root-key-as-json-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/pgsodium", + "path": "/v1/projects/replayfixtureprojref/pgsodium", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "13" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/encryption-update-root-key-rotates-the-vault-root-key/interactions.json b/apps/cli-e2e/fixtures/scenarios/encryption-update-root-key-rotates-the-vault-root-key/interactions.json index 8c29424805..bb79bf9174 100644 --- a/apps/cli-e2e/fixtures/scenarios/encryption-update-root-key-rotates-the-vault-root-key/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/encryption-update-root-key-rotates-the-vault-root-key/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/pgsodium", + "path": "/v1/projects/replayfixtureprojref/pgsodium", "query": {}, "headers": { "accept-encoding": "gzip", @@ -33,7 +33,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -53,15 +53,15 @@ "x-ratelimit-reset": "58" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/functions-delete-deletes-function-successfully/interactions.json b/apps/cli-e2e/fixtures/scenarios/functions-delete-deletes-function-successfully/interactions.json index 6a13d5e336..e3b75f5ae0 100644 --- a/apps/cli-e2e/fixtures/scenarios/functions-delete-deletes-function-successfully/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/functions-delete-deletes-function-successfully/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/functions/hello-world", + "path": "/v1/projects/replayfixtureprojref/functions/hello-world", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/functions-download-downloads-function-successfully/interactions.json b/apps/cli-e2e/fixtures/scenarios/functions-download-downloads-function-successfully/interactions.json index fbf89396ff..9faa0f7346 100644 --- a/apps/cli-e2e/fixtures/scenarios/functions-download-downloads-function-successfully/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/functions-download-downloads-function-successfully/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/functions/hello-world/body", + "path": "/v1/projects/replayfixtureprojref/functions/hello-world/body", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/functions-list-includes-debug-output-with-debug/interactions.json b/apps/cli-e2e/fixtures/scenarios/functions-list-includes-debug-output-with-debug/interactions.json index 408e4a81bc..1006d84cc7 100644 --- a/apps/cli-e2e/fixtures/scenarios/functions-list-includes-debug-output-with-debug/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/functions-list-includes-debug-output-with-debug/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/functions", + "path": "/v1/projects/replayfixtureprojref/functions", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "24" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/functions-list-renders-fixture-data-in-output/interactions.json b/apps/cli-e2e/fixtures/scenarios/functions-list-renders-fixture-data-in-output/interactions.json index 7ed0e0e659..1e18d74308 100644 --- a/apps/cli-e2e/fixtures/scenarios/functions-list-renders-fixture-data-in-output/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/functions-list-renders-fixture-data-in-output/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/functions", + "path": "/v1/projects/replayfixtureprojref/functions", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "26" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/functions-list-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/functions-list-returns-json-output-with-output-json/interactions.json index bfb106ed97..4c9525f804 100644 --- a/apps/cli-e2e/fixtures/scenarios/functions-list-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/functions-list-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/functions", + "path": "/v1/projects/replayfixtureprojref/functions", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "25" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/gen-types-generates-typescript-types-from-project/interactions.json b/apps/cli-e2e/fixtures/scenarios/gen-types-generates-typescript-types-from-project/interactions.json index 07cda5a499..513d2118ed 100644 --- a/apps/cli-e2e/fixtures/scenarios/gen-types-generates-typescript-types-from-project/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/gen-types-generates-typescript-types-from-project/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/types/typescript", + "path": "/v1/projects/replayfixtureprojref/types/typescript", "query": { "included_schemas": "public" }, diff --git a/apps/cli-e2e/fixtures/scenarios/gen-types-includes-debug-output-with-debug/interactions.json b/apps/cli-e2e/fixtures/scenarios/gen-types-includes-debug-output-with-debug/interactions.json index 07cda5a499..513d2118ed 100644 --- a/apps/cli-e2e/fixtures/scenarios/gen-types-includes-debug-output-with-debug/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/gen-types-includes-debug-output-with-debug/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/types/typescript", + "path": "/v1/projects/replayfixtureprojref/types/typescript", "query": { "included_schemas": "public" }, diff --git a/apps/cli-e2e/fixtures/scenarios/link-links-project-successfully/interactions.json b/apps/cli-e2e/fixtures/scenarios/link-links-project-successfully/interactions.json index ba7f580369..f6f94156a5 100644 --- a/apps/cli-e2e/fixtures/scenarios/link-links-project-successfully/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/link-links-project-successfully/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -18,8 +18,8 @@ "content-type": "application/json; charset=utf-8" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", "name": "cli-e2e-test", "organization_id": "test-org", "organization_slug": "test-org", @@ -27,7 +27,7 @@ "created_at": "2000-01-01T00:00:00Z", "status": "ACTIVE_HEALTHY", "database": { - "host": "__PROJECT_REF__.supabase.co", + "host": "replayfixtureprojref.supabase.co", "postgres_engine": "postgres", "release_channel": "ga", "version": "15.6.1.143" @@ -38,7 +38,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/api-keys", + "path": "/v1/projects/replayfixtureprojref/api-keys", "query": { "reveal": "true" }, diff --git a/apps/cli-e2e/fixtures/scenarios/link-links-when-only-supabase-project-id-is-set-in-non-tty/interactions.json b/apps/cli-e2e/fixtures/scenarios/link-links-when-only-supabase-project-id-is-set-in-non-tty/interactions.json index b279ebcbf0..c6976da175 100644 --- a/apps/cli-e2e/fixtures/scenarios/link-links-when-only-supabase-project-id-is-set-in-non-tty/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/link-links-when-only-supabase-project-id-is-set-in-non-tty/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -22,15 +22,15 @@ "x-ratelimit-reset": "24" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" @@ -42,7 +42,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/api-keys", + "path": "/v1/projects/replayfixtureprojref/api-keys", "query": { "reveal": "true" }, @@ -114,7 +114,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -139,7 +139,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions", + "path": "/v1/projects/replayfixtureprojref/network-restrictions", "query": {}, "headers": { "accept-encoding": "gzip", @@ -172,7 +172,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/pooler", + "path": "/v1/projects/replayfixtureprojref/config/database/pooler", "query": {}, "headers": { "accept-encoding": "gzip", @@ -193,18 +193,18 @@ }, "body": [ { - "identifier": "__PROJECT_REF__", + "identifier": "replayfixtureprojref", "database_type": "PRIMARY", - "db_user": "postgres.__PROJECT_REF__", + "db_user": "postgres.replayfixtureprojref", "db_host": "aws-0-us-east-1.pooler.supabase.green", "db_port": 6543, "db_name": "postgres", - "connection_string": "postgresql://postgres.__PROJECT_REF__:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.green:6543/postgres", + "connection_string": "postgresql://postgres.replayfixtureprojref:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.green:6543/postgres", "pool_mode": "transaction", "default_pool_size": null, "max_client_conn": null, "is_using_scram_auth": true, - "connectionString": "postgresql://postgres.__PROJECT_REF__:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.green:6543/postgres" + "connectionString": "postgresql://postgres.replayfixtureprojref:[YOUR-PASSWORD]@aws-0-us-east-1.pooler.supabase.green:6543/postgres" } ] } @@ -212,7 +212,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/postgrest", + "path": "/v1/projects/replayfixtureprojref/postgrest", "query": {}, "headers": { "accept-encoding": "gzip", @@ -244,7 +244,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/storage", + "path": "/v1/projects/replayfixtureprojref/config/storage", "query": {}, "headers": { "accept-encoding": "gzip", @@ -303,7 +303,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth", + "path": "/v1/projects/replayfixtureprojref/config/auth", "query": {}, "headers": { "accept-encoding": "gzip", @@ -355,6 +355,7 @@ "hook_before_user_created_secrets": null, "nimbus_oauth_client_id": null, "nimbus_oauth_client_secret": null, + "nimbus_oauth_email_optional": null, "external_anonymous_users_enabled": false, "external_apple_additional_client_ids": null, "external_apple_client_id": null, diff --git a/apps/cli-e2e/fixtures/scenarios/link-skip-pooler-uses-direct-connection/interactions.json b/apps/cli-e2e/fixtures/scenarios/link-skip-pooler-uses-direct-connection/interactions.json index ba7f580369..f6f94156a5 100644 --- a/apps/cli-e2e/fixtures/scenarios/link-skip-pooler-uses-direct-connection/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/link-skip-pooler-uses-direct-connection/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -18,8 +18,8 @@ "content-type": "application/json; charset=utf-8" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", "name": "cli-e2e-test", "organization_id": "test-org", "organization_slug": "test-org", @@ -27,7 +27,7 @@ "created_at": "2000-01-01T00:00:00Z", "status": "ACTIVE_HEALTHY", "database": { - "host": "__PROJECT_REF__.supabase.co", + "host": "replayfixtureprojref.supabase.co", "postgres_engine": "postgres", "release_channel": "ga", "version": "15.6.1.143" @@ -38,7 +38,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/api-keys", + "path": "/v1/projects/replayfixtureprojref/api-keys", "query": { "reveal": "true" }, diff --git a/apps/cli-e2e/fixtures/scenarios/network-bans-get-includes-debug-output-with-debug/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-bans-get-includes-debug-output-with-debug/interactions.json index e8905394e8..35aa107156 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-bans-get-includes-debug-output-with-debug/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-bans-get-includes-debug-output-with-debug/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/network-bans/retrieve", + "path": "/v1/projects/replayfixtureprojref/network-bans/retrieve", "query": {}, "headers": { "accept-encoding": "gzip", @@ -30,7 +30,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -50,15 +50,15 @@ "x-ratelimit-reset": "8" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/network-bans-get-renders-fixture-data-in-output/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-bans-get-renders-fixture-data-in-output/interactions.json index 41017bfbfa..03aa7ad50a 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-bans-get-renders-fixture-data-in-output/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-bans-get-renders-fixture-data-in-output/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/network-bans/retrieve", + "path": "/v1/projects/replayfixtureprojref/network-bans/retrieve", "query": {}, "headers": { "accept-encoding": "gzip", @@ -30,7 +30,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -50,15 +50,15 @@ "x-ratelimit-reset": "10" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/network-bans-get-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-bans-get-returns-json-output-with-output-json/interactions.json index 633c051b8f..86b4d71aa5 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-bans-get-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-bans-get-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/network-bans/retrieve", + "path": "/v1/projects/replayfixtureprojref/network-bans/retrieve", "query": {}, "headers": { "accept-encoding": "gzip", @@ -30,7 +30,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -50,15 +50,15 @@ "x-ratelimit-reset": "9" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/network-bans-remove-exits-non-zero-with-invalid-ip-address/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-bans-remove-exits-non-zero-with-invalid-ip-address/interactions.json index 6dcedb0209..9e641fefb7 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-bans-remove-exits-non-zero-with-invalid-ip-address/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-bans-remove-exits-non-zero-with-invalid-ip-address/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/network-bans-remove-removes-ip-from-ban-list/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-bans-remove-removes-ip-from-ban-list/interactions.json index 864b9ca700..d7a1acf023 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-bans-remove-removes-ip-from-ban-list/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-bans-remove-removes-ip-from-ban-list/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/network-bans", + "path": "/v1/projects/replayfixtureprojref/network-bans", "query": {}, "headers": { "accept-encoding": "gzip", @@ -31,7 +31,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -51,15 +51,15 @@ "x-ratelimit-reset": "5" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/network-bans-remove-removes-multiple-ips-from-ban-list/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-bans-remove-removes-multiple-ips-from-ban-list/interactions.json index e10100c7a8..9629d333dd 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-bans-remove-removes-multiple-ips-from-ban-list/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-bans-remove-removes-multiple-ips-from-ban-list/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/network-bans", + "path": "/v1/projects/replayfixtureprojref/network-bans", "query": {}, "headers": { "accept-encoding": "gzip", @@ -31,7 +31,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -51,15 +51,15 @@ "x-ratelimit-reset": "4" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/network-restrictions-get-renders-fixture-data-in-output/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-restrictions-get-renders-fixture-data-in-output/interactions.json index 40e570e6a1..cfb53d4583 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-restrictions-get-renders-fixture-data-in-output/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-restrictions-get-renders-fixture-data-in-output/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions", + "path": "/v1/projects/replayfixtureprojref/network-restrictions", "query": {}, "headers": { "accept-encoding": "gzip", @@ -34,7 +34,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -54,15 +54,15 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/network-restrictions-get-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-restrictions-get-returns-json-output-with-output-json/interactions.json index 58a0472b91..82fe595445 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-restrictions-get-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-restrictions-get-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions", + "path": "/v1/projects/replayfixtureprojref/network-restrictions", "query": {}, "headers": { "accept-encoding": "gzip", @@ -34,7 +34,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -54,15 +54,15 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-appends-to-existing-restrictions/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-appends-to-existing-restrictions/interactions.json index dce5e8df40..482e20f8c6 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-appends-to-existing-restrictions/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-appends-to-existing-restrictions/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "PATCH", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions", + "path": "/v1/projects/replayfixtureprojref/network-restrictions", "query": {}, "headers": { "accept-encoding": "gzip", @@ -58,7 +58,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -78,15 +78,15 @@ "x-ratelimit-reset": "49" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-bypasses-cidr-validation-checks/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-bypasses-cidr-validation-checks/interactions.json index a467ac4948..53f36a1563 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-bypasses-cidr-validation-checks/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-bypasses-cidr-validation-checks/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions/apply", + "path": "/v1/projects/replayfixtureprojref/network-restrictions/apply", "query": {}, "headers": { "accept-encoding": "gzip", @@ -39,7 +39,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -59,15 +59,15 @@ "x-ratelimit-reset": "45" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-exits-non-zero-with-invalid-cidr-format/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-exits-non-zero-with-invalid-cidr-format/interactions.json index 8acde92835..08b01f8470 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-exits-non-zero-with-invalid-cidr-format/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-exits-non-zero-with-invalid-cidr-format/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-sets-cidr-allowlist/interactions.json b/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-sets-cidr-allowlist/interactions.json index cc9be8cd40..4540b3d181 100644 --- a/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-sets-cidr-allowlist/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/network-restrictions-update-sets-cidr-allowlist/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/network-restrictions/apply", + "path": "/v1/projects/replayfixtureprojref/network-restrictions/apply", "query": {}, "headers": { "accept-encoding": "gzip", @@ -39,7 +39,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -59,15 +59,15 @@ "x-ratelimit-reset": "54" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/orgs-create-creates-organization-with-name/interactions.json b/apps/cli-e2e/fixtures/scenarios/orgs-create-creates-organization-with-name/interactions.json index 97b40f9e21..c35add8241 100644 --- a/apps/cli-e2e/fixtures/scenarios/orgs-create-creates-organization-with-name/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/orgs-create-creates-organization-with-name/interactions.json @@ -21,8 +21,8 @@ "content-type": "application/json; charset=utf-8" }, "body": { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "My Test Org" } } diff --git a/apps/cli-e2e/fixtures/scenarios/orgs-list-includes-debug-output-with-debug/interactions.json b/apps/cli-e2e/fixtures/scenarios/orgs-list-includes-debug-output-with-debug/interactions.json index 242c6ca234..1f359a2bd8 100644 --- a/apps/cli-e2e/fixtures/scenarios/orgs-list-includes-debug-output-with-debug/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/orgs-list-includes-debug-output-with-debug/interactions.json @@ -23,13 +23,13 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Org" }, { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "CLIPort-TEST" } ] diff --git a/apps/cli-e2e/fixtures/scenarios/orgs-list-renders-org-data/interactions.json b/apps/cli-e2e/fixtures/scenarios/orgs-list-renders-org-data/interactions.json index e497a526a5..9fa76f7eab 100644 --- a/apps/cli-e2e/fixtures/scenarios/orgs-list-renders-org-data/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/orgs-list-renders-org-data/interactions.json @@ -23,13 +23,13 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Org" }, { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "CLIPort-TEST" } ] diff --git a/apps/cli-e2e/fixtures/scenarios/orgs-list-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/orgs-list-returns-json-output-with-output-json/interactions.json index 3c53dab9ab..31910e9c4d 100644 --- a/apps/cli-e2e/fixtures/scenarios/orgs-list-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/orgs-list-returns-json-output-with-output-json/interactions.json @@ -23,13 +23,13 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Org" }, { - "id": "__PROJECT_REF__", - "slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "slug": "replayfixtureprojref", "name": "CLIPort-TEST" } ] diff --git a/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-no-restart-removes-config-without-restarting-postgres/interactions.json b/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-no-restart-removes-config-without-restarting-postgres/interactions.json index 821e27abda..4914053cc2 100644 --- a/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-no-restart-removes-config-without-restarting-postgres/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-no-restart-removes-config-without-restarting-postgres/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -56,7 +56,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -76,15 +76,15 @@ "x-ratelimit-reset": "30" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-removes-config-override/interactions.json b/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-removes-config-override/interactions.json index 9225167c2b..9a60b499d6 100644 --- a/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-removes-config-override/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-removes-config-override/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -56,7 +56,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -76,15 +76,15 @@ "x-ratelimit-reset": "38" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-removes-multiple-config-keys/interactions.json b/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-removes-multiple-config-keys/interactions.json index 07a7923cf3..f701e2faab 100644 --- a/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-removes-multiple-config-keys/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/postgres-config-delete-removes-multiple-config-keys/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -54,7 +54,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -74,15 +74,15 @@ "x-ratelimit-reset": "33" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/postgres-config-get-debug-shows-http-trace/interactions.json b/apps/cli-e2e/fixtures/scenarios/postgres-config-get-debug-shows-http-trace/interactions.json index 900dec87ec..abc329c512 100644 --- a/apps/cli-e2e/fixtures/scenarios/postgres-config-get-debug-shows-http-trace/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/postgres-config-get-debug-shows-http-trace/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "58" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/postgres-config-get-renders-config-overrides/interactions.json b/apps/cli-e2e/fixtures/scenarios/postgres-config-get-renders-config-overrides/interactions.json index 92e5ac77c7..75381aed00 100644 --- a/apps/cli-e2e/fixtures/scenarios/postgres-config-get-renders-config-overrides/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/postgres-config-get-renders-config-overrides/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/postgres-config-get-returns-config-overrides-as-json-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/postgres-config-get-returns-config-overrides-as-json-with-output-json/interactions.json index dc5b651387..5d0e6357c0 100644 --- a/apps/cli-e2e/fixtures/scenarios/postgres-config-get-returns-config-overrides-as-json-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/postgres-config-get-returns-config-overrides-as-json-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/postgres-config-update-no-restart-applies-config-without-restarting-postgres/interactions.json b/apps/cli-e2e/fixtures/scenarios/postgres-config-update-no-restart-applies-config-without-restarting-postgres/interactions.json index 12ec1ea4be..678c834a5d 100644 --- a/apps/cli-e2e/fixtures/scenarios/postgres-config-update-no-restart-applies-config-without-restarting-postgres/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/postgres-config-update-no-restart-applies-config-without-restarting-postgres/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -61,7 +61,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -81,15 +81,15 @@ "x-ratelimit-reset": "42" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/postgres-config-update-replace-existing-overrides-replaces-all-overrides/interactions.json b/apps/cli-e2e/fixtures/scenarios/postgres-config-update-replace-existing-overrides-replaces-all-overrides/interactions.json index 17d60e2835..4b7fc25fc7 100644 --- a/apps/cli-e2e/fixtures/scenarios/postgres-config-update-replace-existing-overrides-replaces-all-overrides/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/postgres-config-update-replace-existing-overrides-replaces-all-overrides/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -33,7 +33,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -53,15 +53,15 @@ "x-ratelimit-reset": "45" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/postgres-config-update-sets-multiple-config-overrides/interactions.json b/apps/cli-e2e/fixtures/scenarios/postgres-config-update-sets-multiple-config-overrides/interactions.json index aef13964cb..a6a8783096 100644 --- a/apps/cli-e2e/fixtures/scenarios/postgres-config-update-sets-multiple-config-overrides/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/postgres-config-update-sets-multiple-config-overrides/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -62,7 +62,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -82,15 +82,15 @@ "x-ratelimit-reset": "49" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/postgres-config-update-sets-single-config-override/interactions.json b/apps/cli-e2e/fixtures/scenarios/postgres-config-update-sets-single-config-override/interactions.json index cfc576b4cf..4f4322a50d 100644 --- a/apps/cli-e2e/fixtures/scenarios/postgres-config-update-sets-single-config-override/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/postgres-config-update-sets-single-config-override/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/database/postgres", + "path": "/v1/projects/replayfixtureprojref/config/database/postgres", "query": {}, "headers": { "accept-encoding": "gzip", @@ -58,7 +58,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -78,15 +78,15 @@ "x-ratelimit-reset": "54" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/projects-api-keys-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/projects-api-keys-returns-json-output-with-output-json/interactions.json index 67877d5c1a..5a5719e8f7 100644 --- a/apps/cli-e2e/fixtures/scenarios/projects-api-keys-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/projects-api-keys-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/api-keys", + "path": "/v1/projects/replayfixtureprojref/api-keys", "query": {}, "headers": { "accept-encoding": "gzip", @@ -72,7 +72,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -92,15 +92,15 @@ "x-ratelimit-reset": "11" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/projects-api-keys-shows-default-and-anon-keys/interactions.json b/apps/cli-e2e/fixtures/scenarios/projects-api-keys-shows-default-and-anon-keys/interactions.json index 99b3e6b9a4..c7acb2ada2 100644 --- a/apps/cli-e2e/fixtures/scenarios/projects-api-keys-shows-default-and-anon-keys/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/projects-api-keys-shows-default-and-anon-keys/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/api-keys", + "path": "/v1/projects/replayfixtureprojref/api-keys", "query": {}, "headers": { "accept-encoding": "gzip", @@ -72,7 +72,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -92,15 +92,15 @@ "x-ratelimit-reset": "13" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/projects-create-creates-project-with-required-flags/interactions.json b/apps/cli-e2e/fixtures/scenarios/projects-create-creates-project-with-required-flags/interactions.json index f1f64d4316..7b718e4b03 100644 --- a/apps/cli-e2e/fixtures/scenarios/projects-create-creates-project-with-required-flags/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/projects-create-creates-project-with-required-flags/interactions.json @@ -16,7 +16,7 @@ "db_pass": "password123", "desired_instance_size": "micro", "name": "my-project", - "organization_slug": "__PROJECT_REF__", + "organization_slug": "replayfixtureprojref", "region": "us-east-1" } }, @@ -30,10 +30,10 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "my-project", "region": "us-east-1", "created_at": "2000-01-01T00:00:00Z", @@ -44,7 +44,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -64,15 +64,15 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "my-project", "region": "us-east-1", "status": "COMING_UP", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/projects-delete-deletes-project-with-yes-flag/interactions.json b/apps/cli-e2e/fixtures/scenarios/projects-delete-deletes-project-with-yes-flag/interactions.json index e9dc83bf97..dfebb27601 100644 --- a/apps/cli-e2e/fixtures/scenarios/projects-delete-deletes-project-with-yes-flag/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/projects-delete-deletes-project-with-yes-flag/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -20,7 +20,7 @@ "body": { "id": 1, "name": "cli-e2e-test", - "ref": "__PROJECT_REF__" + "ref": "replayfixtureprojref" } } } diff --git a/apps/cli-e2e/fixtures/scenarios/projects-list-includes-debug-output-with-debug/interactions.json b/apps/cli-e2e/fixtures/scenarios/projects-list-includes-debug-output-with-debug/interactions.json index a331688dde..eded40a8e4 100644 --- a/apps/cli-e2e/fixtures/scenarios/projects-list-includes-debug-output-with-debug/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/projects-list-includes-debug-output-with-debug/interactions.json @@ -23,15 +23,15 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Project", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.084", "postgres_engine": "17", "release_channel": "ga" @@ -39,15 +39,15 @@ "created_at": "2000-01-01T00:00:00Z" }, { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" @@ -60,7 +60,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -80,15 +80,15 @@ "x-ratelimit-reset": "15" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/projects-list-renders-project-list/interactions.json b/apps/cli-e2e/fixtures/scenarios/projects-list-renders-project-list/interactions.json index 0f1dd8a924..3adca9f7a2 100644 --- a/apps/cli-e2e/fixtures/scenarios/projects-list-renders-project-list/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/projects-list-renders-project-list/interactions.json @@ -23,15 +23,15 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Project", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.084", "postgres_engine": "17", "release_channel": "ga" @@ -39,15 +39,15 @@ "created_at": "2000-01-01T00:00:00Z" }, { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" @@ -60,7 +60,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -80,15 +80,15 @@ "x-ratelimit-reset": "17" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/projects-list-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/projects-list-returns-json-output-with-output-json/interactions.json index 97abd08a53..2a2bc47579 100644 --- a/apps/cli-e2e/fixtures/scenarios/projects-list-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/projects-list-returns-json-output-with-output-json/interactions.json @@ -23,15 +23,15 @@ }, "body": [ { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "colum.ferry@supabase.io's Project", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.084", "postgres_engine": "17", "release_channel": "ga" @@ -39,15 +39,15 @@ "created_at": "2000-01-01T00:00:00Z" }, { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" @@ -60,7 +60,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -80,15 +80,15 @@ "x-ratelimit-reset": "16" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/secrets-list-includes-debug-output-with-debug/interactions.json b/apps/cli-e2e/fixtures/scenarios/secrets-list-includes-debug-output-with-debug/interactions.json index 5fadaa09e3..f2f535dc35 100644 --- a/apps/cli-e2e/fixtures/scenarios/secrets-list-includes-debug-output-with-debug/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/secrets-list-includes-debug-output-with-debug/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "58" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/secrets-list-renders-fixture-data-in-output/interactions.json b/apps/cli-e2e/fixtures/scenarios/secrets-list-renders-fixture-data-in-output/interactions.json index 881b16c20e..43e0e51f84 100644 --- a/apps/cli-e2e/fixtures/scenarios/secrets-list-renders-fixture-data-in-output/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/secrets-list-renders-fixture-data-in-output/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/secrets-list-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/secrets-list-returns-json-output-with-output-json/interactions.json index e47bddc8fd..2f13f43970 100644 --- a/apps/cli-e2e/fixtures/scenarios/secrets-list-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/secrets-list-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", @@ -27,7 +27,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,15 +47,15 @@ "x-ratelimit-reset": "59" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/secrets-set-exits-non-zero-when-env-file-not-found/interactions.json b/apps/cli-e2e/fixtures/scenarios/secrets-set-exits-non-zero-when-env-file-not-found/interactions.json index b090bbeaed..ce2ca6ec9f 100644 --- a/apps/cli-e2e/fixtures/scenarios/secrets-set-exits-non-zero-when-env-file-not-found/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/secrets-set-exits-non-zero-when-env-file-not-found/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-a-single-secret/interactions.json b/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-a-single-secret/interactions.json index 5372e19be3..1492ce7513 100644 --- a/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-a-single-secret/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-a-single-secret/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", @@ -33,7 +33,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -53,15 +53,15 @@ "x-ratelimit-reset": "56" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-multiple-secrets/interactions.json b/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-multiple-secrets/interactions.json index 70143d839b..6e54c07f13 100644 --- a/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-multiple-secrets/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-multiple-secrets/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", @@ -37,7 +37,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -57,15 +57,15 @@ "x-ratelimit-reset": "55" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-secrets-from-env-file/interactions.json b/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-secrets-from-env-file/interactions.json index 55ab3fdfca..c0392f0ef3 100644 --- a/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-secrets-from-env-file/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/secrets-set-sets-secrets-from-env-file/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", @@ -33,7 +33,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -53,15 +53,15 @@ "x-ratelimit-reset": "54" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/secrets-unset-removes-a-secret/interactions.json b/apps/cli-e2e/fixtures/scenarios/secrets-unset-removes-a-secret/interactions.json index 911375fde0..b895883b4e 100644 --- a/apps/cli-e2e/fixtures/scenarios/secrets-unset-removes-a-secret/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/secrets-unset-removes-a-secret/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", @@ -28,7 +28,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -48,15 +48,15 @@ "x-ratelimit-reset": "51" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/secrets-unset-removes-multiple-secrets/interactions.json b/apps/cli-e2e/fixtures/scenarios/secrets-unset-removes-multiple-secrets/interactions.json index d92197e920..42c3f0ab43 100644 --- a/apps/cli-e2e/fixtures/scenarios/secrets-unset-removes-multiple-secrets/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/secrets-unset-removes-multiple-secrets/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", @@ -37,7 +37,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -57,15 +57,15 @@ "x-ratelimit-reset": "50" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" @@ -77,7 +77,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/secrets", + "path": "/v1/projects/replayfixtureprojref/secrets", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/seed-buckets-creates-buckets-defined-in-config/interactions.json b/apps/cli-e2e/fixtures/scenarios/seed-buckets-creates-buckets-defined-in-config/interactions.json index 78125524cf..eab883b62a 100644 --- a/apps/cli-e2e/fixtures/scenarios/seed-buckets-creates-buckets-defined-in-config/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/seed-buckets-creates-buckets-defined-in-config/interactions.json @@ -20,7 +20,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, @@ -54,7 +54,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, @@ -86,7 +86,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/scenarios/services-lists-known-service-images/interactions.json b/apps/cli-e2e/fixtures/scenarios/services-lists-known-service-images/interactions.json index 80579ad512..ad952e4020 100644 --- a/apps/cli-e2e/fixtures/scenarios/services-lists-known-service-images/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/services-lists-known-service-images/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/api-keys", + "path": "/v1/projects/replayfixtureprojref/api-keys", "query": { "reveal": "true" }, @@ -74,7 +74,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -94,15 +94,15 @@ "x-ratelimit-reset": "34" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" @@ -114,7 +114,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -134,15 +134,15 @@ "x-ratelimit-reset": "33" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/snippets-download-prints-sql-content-to-stdout/interactions.json b/apps/cli-e2e/fixtures/scenarios/snippets-download-prints-sql-content-to-stdout/interactions.json index 4fbf1b91e1..d95878e319 100644 --- a/apps/cli-e2e/fixtures/scenarios/snippets-download-prints-sql-content-to-stdout/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/snippets-download-prints-sql-content-to-stdout/interactions.json @@ -29,6 +29,7 @@ "favorite": false, "id": "__UUID__", "inserted_at": "2000-01-01T00:00:00Z", + "description": null, "name": "test-snippet", "owner": { "id": 1, @@ -51,7 +52,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -71,15 +72,15 @@ "x-ratelimit-reset": "45" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/snippets-list-debug-shows-http-trace/interactions.json b/apps/cli-e2e/fixtures/scenarios/snippets-list-debug-shows-http-trace/interactions.json index bfd8a503d6..6770f4fe70 100644 --- a/apps/cli-e2e/fixtures/scenarios/snippets-list-debug-shows-http-trace/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/snippets-list-debug-shows-http-trace/interactions.json @@ -4,7 +4,7 @@ "method": "GET", "path": "/v1/snippets", "query": { - "project_ref": "__PROJECT_REF__" + "project_ref": "replayfixtureprojref" }, "headers": { "accept-encoding": "gzip", @@ -31,7 +31,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -51,15 +51,15 @@ "x-ratelimit-reset": "60" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/snippets-list-renders-snippet-table-with-id-and-name-columns/interactions.json b/apps/cli-e2e/fixtures/scenarios/snippets-list-renders-snippet-table-with-id-and-name-columns/interactions.json index 94fece5304..a882be44b9 100644 --- a/apps/cli-e2e/fixtures/scenarios/snippets-list-renders-snippet-table-with-id-and-name-columns/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/snippets-list-renders-snippet-table-with-id-and-name-columns/interactions.json @@ -4,7 +4,7 @@ "method": "GET", "path": "/v1/snippets", "query": { - "project_ref": "__PROJECT_REF__" + "project_ref": "replayfixtureprojref" }, "headers": { "accept-encoding": "gzip", @@ -31,7 +31,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -51,15 +51,15 @@ "x-ratelimit-reset": "5" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/snippets-list-returns-snippet-list-as-json-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/snippets-list-returns-snippet-list-as-json-with-output-json/interactions.json index 335ed811d4..15666a2ee4 100644 --- a/apps/cli-e2e/fixtures/scenarios/snippets-list-returns-snippet-list-as-json-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/snippets-list-returns-snippet-list-as-json-with-output-json/interactions.json @@ -4,7 +4,7 @@ "method": "GET", "path": "/v1/snippets", "query": { - "project_ref": "__PROJECT_REF__" + "project_ref": "replayfixtureprojref" }, "headers": { "accept-encoding": "gzip", @@ -31,7 +31,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -51,15 +51,15 @@ "x-ratelimit-reset": "2" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-get-renders-fixture-data-in-output/interactions.json b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-get-renders-fixture-data-in-output/interactions.json index 830ce723de..df9435b0cb 100644 --- a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-get-renders-fixture-data-in-output/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-get-renders-fixture-data-in-output/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/ssl-enforcement", + "path": "/v1/projects/replayfixtureprojref/ssl-enforcement", "query": {}, "headers": { "accept-encoding": "gzip", @@ -32,7 +32,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -52,15 +52,15 @@ "x-ratelimit-reset": "44" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-get-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-get-returns-json-output-with-output-json/interactions.json index 561cd24a62..a0c952992c 100644 --- a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-get-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-get-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/ssl-enforcement", + "path": "/v1/projects/replayfixtureprojref/ssl-enforcement", "query": {}, "headers": { "accept-encoding": "gzip", @@ -32,7 +32,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -52,15 +52,15 @@ "x-ratelimit-reset": "42" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-disables-ssl-enforcement/interactions.json b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-disables-ssl-enforcement/interactions.json index 54c63a6650..2c29e6700d 100644 --- a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-disables-ssl-enforcement/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-disables-ssl-enforcement/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/ssl-enforcement", + "path": "/v1/projects/replayfixtureprojref/ssl-enforcement", "query": {}, "headers": { "accept-encoding": "gzip", @@ -38,7 +38,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -58,15 +58,15 @@ "x-ratelimit-reset": "29" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-enables-ssl-enforcement/interactions.json b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-enables-ssl-enforcement/interactions.json index f729e171ec..a982b5fc89 100644 --- a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-enables-ssl-enforcement/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-enables-ssl-enforcement/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/ssl-enforcement", + "path": "/v1/projects/replayfixtureprojref/ssl-enforcement", "query": {}, "headers": { "accept-encoding": "gzip", @@ -38,7 +38,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -58,15 +58,15 @@ "x-ratelimit-reset": "36" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-exits-non-zero-with-mutually-exclusive-flags/interactions.json b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-exits-non-zero-with-mutually-exclusive-flags/interactions.json index 23341d1d3a..3d628fbee6 100644 --- a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-exits-non-zero-with-mutually-exclusive-flags/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-exits-non-zero-with-mutually-exclusive-flags/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-exits-non-zero-with-no-flags-provided/interactions.json b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-exits-non-zero-with-no-flags-provided/interactions.json index 287698b597..9e692194c6 100644 --- a/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-exits-non-zero-with-no-flags-provided/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/ssl-enforcement-update-exits-non-zero-with-no-flags-provided/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-add-adds-saml-provider-via-metadata-file/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-add-adds-saml-provider-via-metadata-file/interactions.json index feeafc375e..3099f4734a 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-add-adds-saml-provider-via-metadata-file/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-add-adds-saml-provider-via-metadata-file/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers", "query": {}, "headers": { "accept-encoding": "gzip", @@ -48,7 +48,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -68,15 +68,15 @@ "x-ratelimit-reset": "41" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-with-both-metadata-url-and-metadata-file/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-with-both-metadata-url-and-metadata-file/interactions.json index b98259a2bd..03d50e5cb5 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-with-both-metadata-url-and-metadata-file/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-with-both-metadata-url-and-metadata-file/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-with-unreachable-metadata-url/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-with-unreachable-metadata-url/interactions.json index 8589df9bc9..ae09586a2e 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-with-unreachable-metadata-url/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-with-unreachable-metadata-url/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-without-type/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-without-type/interactions.json index aa29b5e589..e6024ce918 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-without-type/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-add-exits-non-zero-without-type/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-info-renders-fixture-data-in-output/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-info-renders-fixture-data-in-output/interactions.json index 818805015d..4121787574 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-info-renders-fixture-data-in-output/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-info-renders-fixture-data-in-output/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -22,15 +22,15 @@ "x-ratelimit-reset": "44" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/sso-info-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-info-returns-json-output-with-output-json/interactions.json index 97effe42c4..e1f7a613e5 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-info-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-info-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -22,15 +22,15 @@ "x-ratelimit-reset": "43" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/sso-list-includes-debug-output-with-debug/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-list-includes-debug-output-with-debug/interactions.json index 52b1f71229..567dfe9b5c 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-list-includes-debug-output-with-debug/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-list-includes-debug-output-with-debug/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "44" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/sso-list-renders-fixture-data-in-output/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-list-renders-fixture-data-in-output/interactions.json index 84a58914fc..b4c9d3b144 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-list-renders-fixture-data-in-output/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-list-renders-fixture-data-in-output/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "47" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/sso-list-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-list-returns-json-output-with-output-json/interactions.json index 1b3ffb11c3..28fa88026b 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-list-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-list-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "46" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/sso-remove-removes-a-provider/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-remove-removes-a-provider/interactions.json index 9aaf05e057..90f7f2d4f5 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-remove-removes-a-provider/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-remove-removes-a-provider/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-show-renders-fixture-data-in-output/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-show-renders-fixture-data-in-output/interactions.json index a62ab85658..ed656af6cd 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-show-renders-fixture-data-in-output/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-show-renders-fixture-data-in-output/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-show-returns-json-output-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-show-returns-json-output-with-output-json/interactions.json index a62ab85658..ed656af6cd 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-show-returns-json-output-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-show-returns-json-output-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-show-shows-raw-saml-metadata-xml-with-metadata/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-show-shows-raw-saml-metadata-xml-with-metadata/interactions.json index 3a4c43c3e0..fc74a58403 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-show-shows-raw-saml-metadata-xml-with-metadata/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-show-shows-raw-saml-metadata-xml-with-metadata/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-update-appends-domain-with-add-domains/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-update-appends-domain-with-add-domains/interactions.json index 69024725a6..f8bb4939ac 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-update-appends-domain-with-add-domains/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-update-appends-domain-with-add-domains/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,7 +47,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-update-exits-non-zero-with-domains-and-add-domains/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-update-exits-non-zero-with-domains-and-add-domains/interactions.json index e33a201bee..5ad40ab113 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-update-exits-non-zero-with-domains-and-add-domains/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-update-exits-non-zero-with-domains-and-add-domains/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-update-removes-domain-with-remove-domains/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-update-removes-domain-with-remove-domains/interactions.json index 36f75d5545..7fe3a1e4fd 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-update-removes-domain-with-remove-domains/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-update-removes-domain-with-remove-domains/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,7 +47,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-update-replaces-domains-with-domains/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-update-replaces-domains-with-domains/interactions.json index 48bdcbd09e..3dc51e5982 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-update-replaces-domains-with-domains/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-update-replaces-domains-with-domains/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,7 +47,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/sso-update-updates-metadata-via-metadata-file/interactions.json b/apps/cli-e2e/fixtures/scenarios/sso-update-updates-metadata-via-metadata-file/interactions.json index e0f4d584fd..0f0e557f8e 100644 --- a/apps/cli-e2e/fixtures/scenarios/sso-update-updates-metadata-via-metadata-file/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/sso-update-updates-metadata-via-metadata-file/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", @@ -47,7 +47,7 @@ { "request": { "method": "PUT", - "path": "/v1/projects/__PROJECT_REF__/config/auth/sso/providers/__UUID__", + "path": "/v1/projects/replayfixtureprojref/config/auth/sso/providers/__UUID__", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/storage-cp-downloads-file-from-storage/interactions.json b/apps/cli-e2e/fixtures/scenarios/storage-cp-downloads-file-from-storage/interactions.json index 7ebe30ba16..fc030d1e06 100644 --- a/apps/cli-e2e/fixtures/scenarios/storage-cp-downloads-file-from-storage/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/storage-cp-downloads-file-from-storage/interactions.json @@ -23,7 +23,7 @@ "last-modified": "Mon, 27 Apr 2026 10:30:57 GMT", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-robots-tag": "none", "x-smart-cdn": "true" diff --git a/apps/cli-e2e/fixtures/scenarios/storage-cp-passes-cache-control-header-on-upload/interactions.json b/apps/cli-e2e/fixtures/scenarios/storage-cp-passes-cache-control-header-on-upload/interactions.json index 230c6077cd..858845ecb8 100644 --- a/apps/cli-e2e/fixtures/scenarios/storage-cp-passes-cache-control-header-on-upload/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/storage-cp-passes-cache-control-header-on-upload/interactions.json @@ -23,7 +23,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { diff --git a/apps/cli-e2e/fixtures/scenarios/storage-cp-passes-content-type-header-on-upload/interactions.json b/apps/cli-e2e/fixtures/scenarios/storage-cp-passes-content-type-header-on-upload/interactions.json index 3285ccf1a7..6b1283552a 100644 --- a/apps/cli-e2e/fixtures/scenarios/storage-cp-passes-content-type-header-on-upload/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/storage-cp-passes-content-type-header-on-upload/interactions.json @@ -23,7 +23,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { diff --git a/apps/cli-e2e/fixtures/scenarios/storage-cp-uploads-local-file-to-storage/interactions.json b/apps/cli-e2e/fixtures/scenarios/storage-cp-uploads-local-file-to-storage/interactions.json index c5cffe3abf..c6dac8a056 100644 --- a/apps/cli-e2e/fixtures/scenarios/storage-cp-uploads-local-file-to-storage/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/storage-cp-uploads-local-file-to-storage/interactions.json @@ -23,7 +23,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { diff --git a/apps/cli-e2e/fixtures/scenarios/storage-ls-lists-objects-in-bucket/interactions.json b/apps/cli-e2e/fixtures/scenarios/storage-ls-lists-objects-in-bucket/interactions.json index e7a2cfebb5..514b26f72f 100644 --- a/apps/cli-e2e/fixtures/scenarios/storage-ls-lists-objects-in-bucket/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/storage-ls-lists-objects-in-bucket/interactions.json @@ -25,7 +25,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/scenarios/storage-ls-lists-objects-recursively/interactions.json b/apps/cli-e2e/fixtures/scenarios/storage-ls-lists-objects-recursively/interactions.json index e7a2cfebb5..514b26f72f 100644 --- a/apps/cli-e2e/fixtures/scenarios/storage-ls-lists-objects-recursively/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/storage-ls-lists-objects-recursively/interactions.json @@ -25,7 +25,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/scenarios/storage-mv-moves-file-within-bucket/interactions.json b/apps/cli-e2e/fixtures/scenarios/storage-mv-moves-file-within-bucket/interactions.json index 4d33f56cdb..0c2a02322a 100644 --- a/apps/cli-e2e/fixtures/scenarios/storage-mv-moves-file-within-bucket/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/storage-mv-moves-file-within-bucket/interactions.json @@ -23,7 +23,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { @@ -59,7 +59,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/scenarios/storage-rm-removes-a-file-from-storage/interactions.json b/apps/cli-e2e/fixtures/scenarios/storage-rm-removes-a-file-from-storage/interactions.json index 033a1b257f..299fc71e8d 100644 --- a/apps/cli-e2e/fixtures/scenarios/storage-rm-removes-a-file-from-storage/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/storage-rm-removes-a-file-from-storage/interactions.json @@ -23,7 +23,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { @@ -57,7 +57,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/scenarios/storage-rm-removes-multiple-files/interactions.json b/apps/cli-e2e/fixtures/scenarios/storage-rm-removes-multiple-files/interactions.json index 38d3618105..70b520d648 100644 --- a/apps/cli-e2e/fixtures/scenarios/storage-rm-removes-multiple-files/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/storage-rm-removes-multiple-files/interactions.json @@ -23,7 +23,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { @@ -56,7 +56,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__" }, "body": { @@ -90,7 +90,7 @@ "content-type": "application/json; charset=utf-8", "sb-gateway-mode": "direct", "sb-gateway-version": "1", - "sb-project-ref": "__PROJECT_REF__", + "sb-project-ref": "replayfixtureprojref", "sb-request-id": "__UUID__", "x-content-type-options": "nosniff" }, diff --git a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-activate-activates-desired-vanity-subdomain/interactions.json b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-activate-activates-desired-vanity-subdomain/interactions.json index f506480032..925385f399 100644 --- a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-activate-activates-desired-vanity-subdomain/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-activate-activates-desired-vanity-subdomain/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain/activate", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain/activate", "query": {}, "headers": { "accept-encoding": "gzip", @@ -33,7 +33,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -53,15 +53,15 @@ "x-ratelimit-reset": "19" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-exits-non-zero-without-desired-subdomain-flag/interactions.json b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-exits-non-zero-without-desired-subdomain-flag/interactions.json index b090bbeaed..ce2ca6ec9f 100644 --- a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-exits-non-zero-without-desired-subdomain-flag/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-exits-non-zero-without-desired-subdomain-flag/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", diff --git a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-reports-availability-for-desired-subdomain/interactions.json b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-reports-availability-for-desired-subdomain/interactions.json index 31740f1e3c..d8a047d25e 100644 --- a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-reports-availability-for-desired-subdomain/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-reports-availability-for-desired-subdomain/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain/check-availability", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain/check-availability", "query": {}, "headers": { "accept-encoding": "gzip", @@ -33,7 +33,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -53,15 +53,15 @@ "x-ratelimit-reset": "24" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-returns-availability-as-json-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-returns-availability-as-json-with-output-json/interactions.json index df870ec844..4a94a66950 100644 --- a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-returns-availability-as-json-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-check-availability-returns-availability-as-json-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "POST", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain/check-availability", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain/check-availability", "query": {}, "headers": { "accept-encoding": "gzip", @@ -33,7 +33,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -53,15 +53,15 @@ "x-ratelimit-reset": "22" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-delete-removes-vanity-subdomain/interactions.json b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-delete-removes-vanity-subdomain/interactions.json index 3ba267e522..7cb14cd3d4 100644 --- a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-delete-removes-vanity-subdomain/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-delete-removes-vanity-subdomain/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "DELETE", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain", "query": {}, "headers": { "accept-encoding": "gzip", @@ -26,7 +26,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -46,15 +46,15 @@ "x-ratelimit-reset": "15" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-debug-shows-http-trace/interactions.json b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-debug-shows-http-trace/interactions.json index 9474b4f967..30f10655b6 100644 --- a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-debug-shows-http-trace/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-debug-shows-http-trace/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "25" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-renders-vanity-subdomain-and-status/interactions.json b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-renders-vanity-subdomain-and-status/interactions.json index 653338d012..466fb0aca8 100644 --- a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-renders-vanity-subdomain-and-status/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-renders-vanity-subdomain-and-status/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "27" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-returns-subdomain-config-as-json-with-output-json/interactions.json b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-returns-subdomain-config-as-json-with-output-json/interactions.json index f76d116570..108ab946c1 100644 --- a/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-returns-subdomain-config-as-json-with-output-json/interactions.json +++ b/apps/cli-e2e/fixtures/scenarios/vanity-subdomains-get-returns-subdomain-config-as-json-with-output-json/interactions.json @@ -2,7 +2,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__/vanity-subdomain", + "path": "/v1/projects/replayfixtureprojref/vanity-subdomain", "query": {}, "headers": { "accept-encoding": "gzip", @@ -29,7 +29,7 @@ { "request": { "method": "GET", - "path": "/v1/projects/__PROJECT_REF__", + "path": "/v1/projects/replayfixtureprojref", "query": {}, "headers": { "accept-encoding": "gzip", @@ -49,15 +49,15 @@ "x-ratelimit-reset": "26" }, "body": { - "id": "__PROJECT_REF__", - "ref": "__PROJECT_REF__", - "organization_id": "__PROJECT_REF__", - "organization_slug": "__PROJECT_REF__", + "id": "replayfixtureprojref", + "ref": "replayfixtureprojref", + "organization_id": "replayfixtureprojref", + "organization_slug": "replayfixtureprojref", "name": "cli-e2e-test", "region": "us-east-1", "status": "ACTIVE_HEALTHY", "database": { - "host": "db.__PROJECT_REF__.supabase.red", + "host": "db.replayfixtureprojref.supabase.red", "version": "17.6.1.111", "postgres_engine": "17", "release_channel": "ga" diff --git a/apps/cli-e2e/src/server/placeholder.ts b/apps/cli-e2e/src/server/placeholder.ts index 62c832c569..e0f44a0208 100644 --- a/apps/cli-e2e/src/server/placeholder.ts +++ b/apps/cli-e2e/src/server/placeholder.ts @@ -28,6 +28,14 @@ const LEGACY_KEY_PREFIX_PATTERN = /"prefix": "[A-Za-z0-9]{4,10}"/g; // Only targets 4+ digit integers to avoid touching small status codes or counts. const INTEGER_ID_PATTERN = /"id": \d{4,}/g; +// Project refs and org slugs are 20 lowercase letters. Recording rewrites them to one valid +// ref so fixtures decode against the Management API schema without any replay-time rewriting. +const PROJECT_REF_PATTERN = /\b[a-z]{20}\b/g; +const PROJECT_REF_PATH_SEGMENT = /\/[a-z]{20}(\/|$)/g; + +/** The fixed ref that stands in for every project ref and org slug in recorded fixtures. */ +export const REPLAY_PROJECT_REF = "replayfixtureprojref"; + // A fixed valid ISO 8601 timestamp used in place of real timestamps so that // date-parsing code on response fields doesn't fail. const FIXED_TIMESTAMP = "2000-01-01T00:00:00Z"; @@ -45,10 +53,8 @@ export function applyPlaceholders(input: string): { output: string } { // Docker IDs — normalize before the 20-char project-ref pattern fires on sub-matches. output = output.replace(DOCKER_SHA256_PATTERN, "sha256:__IMAGE_ID__"); output = output.replace(DOCKER_FULL_ID_PATTERN, "__CONTAINER_ID__"); - // 20-char lowercase alpha strings — project refs - output = output.replace(/\b[a-z]{20}\b/g, (match) => - match.length !== 20 ? match : "__PROJECT_REF__", - ); + // 20-char lowercase alpha strings — project refs and org slugs + output = output.replace(PROJECT_REF_PATTERN, REPLAY_PROJECT_REF); output = output.replace(ISO_TIMESTAMP_PATTERN, FIXED_TIMESTAMP); // Rate limit headers vary per recording session — normalise to fixed valid integers. output = output.replace(RATE_LIMIT_RESET_PATTERN, '"x-ratelimit-reset": "60"'); @@ -61,29 +67,6 @@ export function applyPlaceholders(input: string): { output: string } { return { output }; } -// `__PROJECT_REF__` is 15 characters, but the Management API schema requires -// `^[a-z]{20}$`, and response decoding rejects a value that doesn't match. When -// serving a recorded response, substitute any field whose value is *exactly* -// the placeholder back to a valid 20-char ref; leave substring occurrences such -// as `db.__PROJECT_REF__.supabase.red` untouched so placeholder-host assertions still match. -const PROJECT_REF_VALUE = /"__PROJECT_REF__"/g; -const PLACEHOLDER_PROJECT_REF = "abcdefghijklmnopqrst"; - -/** Extract the 20-char project ref from a `/v1/projects/` request path, - * falling back to a stable placeholder ref for refless endpoints (e.g. the - * project list). */ -export function projectRefFromPath(urlPath: string): string { - const match = urlPath.match(/\/projects\/([a-z]{20})(?:\/|$)/); - return match?.[1] ?? PLACEHOLDER_PROJECT_REF; -} - -/** Replace exact-match `__PROJECT_REF__` string values in a serialized JSON - * body with a schema-valid 20-char ref. Operates on the JSON string so only - * full quoted values are rewritten, never substrings. */ -export function restoreProjectRef(json: string, ref: string): string { - return json.replace(PROJECT_REF_VALUE, `"${ref}"`); -} - /** Normalize dynamic segments in a URL path to stable unnumbered placeholders. * Apply this to both the stored fixture path and the incoming request path so * both sides of a scenario comparison transform identically. */ @@ -91,7 +74,7 @@ export function normalizeUrlPath(urlPath: string): string { return ( urlPath .replace(UUID_PATTERN, "__UUID__") - .replace(/\/[a-z]{20}(\/|$)/g, "/__PROJECT_REF__$1") + .replace(PROJECT_REF_PATH_SEGMENT, `/${REPLAY_PROJECT_REF}$1`) // Docker API version (/v1.47/ → /__DOCKER_VERSION__/) so fixture keys are // stable across minor Docker Engine upgrades that bump the negotiated API version. .replace(/\/v1\.\d+(\/|$)/g, "/__DOCKER_VERSION__$1") diff --git a/apps/cli-e2e/src/server/replay-server.ts b/apps/cli-e2e/src/server/replay-server.ts index 23b4a869db..8706bc8736 100644 --- a/apps/cli-e2e/src/server/replay-server.ts +++ b/apps/cli-e2e/src/server/replay-server.ts @@ -9,13 +9,7 @@ import type { FixtureStore, } from "./fixture-loader.ts"; import { loadFixtures, loadScenario } from "./fixture-loader.ts"; -import { - applyPlaceholders, - fixtureKey, - normalizeUrlPath, - projectRefFromPath, - restoreProjectRef, -} from "./placeholder.ts"; +import { applyPlaceholders, fixtureKey, normalizeUrlPath } from "./placeholder.ts"; import { matchFixture, resetCounters, sortBody, type SequenceCounters } from "./request-matcher.ts"; import type { PgFixture, PgMockHandle } from "./pg-mock.ts"; @@ -246,14 +240,14 @@ export async function startReplayServer(options: ReplayServerOptions): Promise= 500; + return Response.json(error.body, { + status: error.status, + ...(retryable ? { headers: { "Retry-After": "0" } } : {}), + }); +} + +/** Build an API response, respecting HTTP no-body status codes (204, 304, 205). */ function buildApiResponse( body: unknown, status: number, headers: Record, - projectRef: string, ): Response { if (status === 204 || status === 304 || status === 205) { return new Response(null, { status, headers }); @@ -776,7 +771,7 @@ function buildApiResponse( if (body === null) { return new Response(null, { status, headers }); } - return new Response(restoreProjectRef(JSON.stringify(body), projectRef), { + return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers }, }); @@ -800,7 +795,6 @@ function serveFromFixtures( result.entry.response.body, result.entry.response.status, result.entry.response.headers, - projectRefFromPath(pathname), ); } @@ -892,7 +886,6 @@ function serveFromScenario( expected.response.body, expected.response.status, expected.response.headers, - projectRefFromPath(pathname), ); } diff --git a/apps/cli-e2e/src/tests/domains.e2e.test.ts b/apps/cli-e2e/src/tests/domains.e2e.test.ts index 2cc7ad8133..72cb5f37bc 100644 --- a/apps/cli-e2e/src/tests/domains.e2e.test.ts +++ b/apps/cli-e2e/src/tests/domains.e2e.test.ts @@ -1,4 +1,5 @@ import { describe, expect } from "vitest"; +import { REPLAY_PROJECT_REF } from "../server/placeholder.ts"; import { testBehaviour } from "./test-context"; import { isRecording, PROJECT_REF } from "./env"; @@ -44,7 +45,9 @@ describe("domains", () => { const result = await run(["domains", "get", "--project-ref", PROJECT_REF]); expect(result.exitCode).toBe(0); - expect(result.stderr).toContain("www.urgsimurksi.xyz CNAME -> __PROJECT_REF__.supabase.red"); + expect(result.stderr).toContain( + `www.urgsimurksi.xyz CNAME -> ${REPLAY_PROJECT_REF}.supabase.red`, + ); }); testBehaviour.skipIf(isRecording)("domain activated", async ({ run }) => { @@ -65,7 +68,7 @@ describe("domains", () => { errors: [], messages: [], result: { - custom_origin_server: "__PROJECT_REF__.supabase.red", + custom_origin_server: `${REPLAY_PROJECT_REF}.supabase.red`, hostname: "www.urgsimurksi.xyz", id: "00000000-0000-0000-0000-000000000000", ownership_verification: { diff --git a/apps/cli-e2e/src/tests/env.ts b/apps/cli-e2e/src/tests/env.ts index 086843f3d8..9bd1c8f850 100644 --- a/apps/cli-e2e/src/tests/env.ts +++ b/apps/cli-e2e/src/tests/env.ts @@ -1,3 +1,5 @@ +import { REPLAY_PROJECT_REF } from "../server/placeholder.ts"; + type CliE2eMode = "replay" | "record"; // Runtime mode. `replay` (default) serves recorded fixtures; `record` proxies to @@ -34,13 +36,12 @@ export const ACCESS_TOKEN = // Region for the fresh recording project. export const REGION = process.env["CLI_E2E_REGION"] ?? "us-east-1"; -// In replay mode any 20-char lowercase alpha string normalises to __PROJECT_REF__ -// in the fixture key. In record mode supply a real project ref via env. -export const PROJECT_REF = process.env["SUPABASE_TEST_PROJECT_REF"] ?? "aaaaaaaaaaaaaaaaaaaa"; +// Recorded fixtures store every project ref and org slug as REPLAY_PROJECT_REF, so replay +// requests use it too. In record mode supply a real project ref via env. +export const PROJECT_REF = process.env["SUPABASE_TEST_PROJECT_REF"] ?? REPLAY_PROJECT_REF; -// In replay mode any 20-char lowercase alpha string normalises to __PROJECT_REF__. // In record mode supply a real org slug via env, or let the resolver derive it. -export const ORG_ID = process.env["SUPABASE_TEST_ORG_ID"] ?? "bbbbbbbbbbbbbbbbbbbb"; +export const ORG_ID = process.env["SUPABASE_TEST_ORG_ID"] ?? REPLAY_PROJECT_REF; // UUID of an existing SAML provider on the staging project. // In replay mode any UUID normalises to __UUID__ in fixture paths. @@ -52,7 +53,7 @@ export const PROVIDER_ID = // In replay mode any UUID normalises to __UUID__ in fixture paths. // In record mode supply a real snippet UUID via env. export const SNIPPET_ID = - process.env["SUPABASE_TEST_SNIPPET_ID"] ?? "00000000-0000-0000-0000-000000000001"; + process.env["SUPABASE_TEST_SNIPPET_ID"] ?? "00000000-0000-4000-8000-000000000001"; // Unix epoch seconds for a PITR restore timestamp within the staging project's backup window. // In replay mode the replay server serves responses in order regardless of the request body value. diff --git a/apps/cli-e2e/src/tests/orgs.e2e.test.ts b/apps/cli-e2e/src/tests/orgs.e2e.test.ts index 8335185225..028ab99aed 100644 --- a/apps/cli-e2e/src/tests/orgs.e2e.test.ts +++ b/apps/cli-e2e/src/tests/orgs.e2e.test.ts @@ -10,7 +10,7 @@ describe("orgs", () => { const result = await run(["orgs", "list"]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("ID"); - expect(result.stdout).toMatch(/[a-z]{20}|__PROJECT_REF__/); + expect(result.stdout).toMatch(/[a-z]{20}/); }); testBehaviour("returns json output with --output json", async ({ run }) => { @@ -77,7 +77,7 @@ describe("orgs", () => { const result = await run(["orgs", "create", ORG_NAME]); expect(result.exitCode).toBe(0); expect(result.stdout).toContain("Created organization:"); - expect(result.stdout).toMatch(/[a-z]{20}|__PROJECT_REF__/); + expect(result.stdout).toMatch(/[a-z]{20}/); }); testBehaviour("exits non-zero without name in non-TTY", async ({ run }) => { diff --git a/apps/cli-e2e/src/tests/projects.e2e.test.ts b/apps/cli-e2e/src/tests/projects.e2e.test.ts index 8efc73aa8e..e34dc96926 100644 --- a/apps/cli-e2e/src/tests/projects.e2e.test.ts +++ b/apps/cli-e2e/src/tests/projects.e2e.test.ts @@ -7,7 +7,7 @@ describe("projects", () => { testBehaviour("renders project list", async ({ run }) => { const result = await run(["projects", "list"]); expect(result.exitCode).toBe(0); - expect(result.stdout).toMatch(/[a-z]{20}|__PROJECT_REF__/); + expect(result.stdout).toMatch(/[a-z]{20}/); expect(result.stdout).toContain("REFERENCE ID"); }); diff --git a/apps/cli-e2e/tests/setup.ts b/apps/cli-e2e/tests/setup.ts index 76395763ac..ae9ea9a70f 100644 --- a/apps/cli-e2e/tests/setup.ts +++ b/apps/cli-e2e/tests/setup.ts @@ -38,8 +38,8 @@ export async function setup({ provide("dockerHostUrl", dockerHostUrl); if (!isRecording) { - // Replay mode — no real API calls; any valid 20-char string works as the - // project ref because fixture paths normalize it to __PROJECT_REF__. + // Replay mode — no real API calls; PROJECT_REF and ORG_ID default to the ref + // recorded fixtures use. provide("projectRef", PROJECT_REF); provide("orgId", ORG_ID); provide("storageBucket", "cli-e2e-bucket"); diff --git a/apps/cli/src/command-internal/branch-ref.resolver.ts b/apps/cli/src/command-internal/branch-ref.resolver.ts index fe95984d8c..062f9538d6 100644 --- a/apps/cli/src/command-internal/branch-ref.resolver.ts +++ b/apps/cli/src/command-internal/branch-ref.resolver.ts @@ -11,12 +11,13 @@ export const branchRef = block("branch-ref", [ { operationId: "v1-get-a-branch-config", kind: "required", - context: "the branch argument is a UUID", + context: "the branch given (argument or typed answer) is a UUID", }, { operationId: "v1-get-a-branch", kind: "required", - context: "the branch argument is a branch name", + context: + "the branch given (argument or typed answer) is a name, not a UUID or a 20-letter project ref", }, ]); diff --git a/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts b/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts index 14b5a8934f..75dea30bc8 100644 --- a/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts +++ b/apps/cli/src/command-internal/command-permissions/command-permissions.unit.test.ts @@ -9,7 +9,6 @@ import { OPERATIONS } from "../../../tests/helpers/operation-table.ts"; import { readPermissions } from "./command-permissions.annotation.ts"; import { GLOBAL_NO_API_EFFECT_FLAGS } from "../../../tests/helpers/permission-global-flags.ts"; import type { CommandPermissions, EntryCondition, OperationEntry } from "./model.ts"; -import { PENDING_COMMANDS } from "../../../tests/helpers/permission-pending-commands.ts"; import { DB_TARGET_FLAGS } from "../../../tests/helpers/permission-registry.ts"; import type { PermissionVariant } from "../../../tests/helpers/permission-registry.ts"; @@ -18,21 +17,19 @@ import type { PermissionVariant } from "../../../tests/helpers/permission-regist * (`db diff --use-pg-schema`, `functions download --legacy-bundle`) stays mapped with a * `context` note instead. */ -const GO_DELEGATED_PATHS: ReadonlySet = new Set([ - "db branch create", - "db branch delete", - "db branch list", - "db branch switch", - "db remote changes", - "gen keys", -]); +const GO_DELEGATED_PATHS: ReadonlySet = new Set([]); /** * Operations a declaration may reference although the spec gives them no `x-fga-permissions`, * keyed by `operationId` with the reason. A missing extension otherwise fails the test, since it * doesn't say whether a scoped token can call the operation. */ -const OPERATIONS_WITHOUT_FGA: ReadonlyMap = new Map([]); +const OPERATIONS_WITHOUT_FGA: ReadonlyMap = new Map([ + [ + "v1-get-profile", + "returns the caller's own profile; the spec declares no FGA permission for it", + ], +]); const VARIANTS: ReadonlyArray<{ readonly name: string; readonly variant: PermissionVariant }> = [ { name: "default backend", variant: undefined }, @@ -63,39 +60,17 @@ function declarationOf(node: TreeNode): CommandPermissions | undefined { describe("command permission mapping completeness", () => { const trees = VARIANTS.map(({ name, variant }) => ({ name, tree: commandTreeFor(variant) })); - const everyLeafPath = new Set( - trees.flatMap(({ tree }) => [...tree].filter(([, node]) => node.isLeaf).map(([path]) => path)), - ); const everyRealFlag = new Set( trees.flatMap(({ tree }) => [...tree.values()].flatMap((node) => [...node.flags])), ); - const pendingPaths = new Set(PENDING_COMMANDS); - - it("lists every pending path once, and only paths that are real leaves", () => { - expect(pendingPaths.size, "PENDING_COMMANDS has a duplicate entry").toBe( - PENDING_COMMANDS.length, - ); - for (const path of pendingPaths) { - expect( - everyLeafPath.has(path), - `pending path "${path}" is not a leaf in any command tree`, - ).toBe(true); - } - }); for (const { name, tree } of trees) { describe(name, () => { const leaves = [...tree].filter(([, node]) => node.isLeaf); - it("declares or pends every leaf, never both", () => { + it("declares permissions on every leaf", () => { for (const [path, node] of leaves) { - const isDeclared = declarationOf(node) !== undefined; - const isPending = pendingPaths.has(path); - expect(isDeclared || isPending, `"${path}" is neither declared nor pending`).toBe(true); - expect( - isDeclared && isPending, - `"${path}" is both declared and pending — remove it from PENDING_COMMANDS`, - ).toBe(false); + expect(declarationOf(node), `"${path}" declares no permissions`).toBeDefined(); } }); @@ -232,6 +207,25 @@ describe("command permission mapping completeness", () => { }); } + it("keeps every go-delegation allowlist path a real leaf that is declared unmapped", () => { + for (const path of GO_DELEGATED_PATHS) { + const declared = trees + .flatMap(({ tree }) => [tree.get(path)]) + .filter((node) => node?.isLeaf === true) + .map((node) => (node === undefined ? undefined : declarationOf(node))); + expect( + declared.length, + `allowlist path "${path}" is not a leaf in any command tree`, + ).toBeGreaterThan(0); + for (const permissions of declared) { + expect( + permissions?.status, + `allowlist path "${path}" isn't declared unmapped — remove it from the allowlist if it was mapped`, + ).toBe("unmapped"); + } + } + }); + it("acknowledges only operations that really lack x-fga-permissions in the bundled spec", () => { for (const operationId of OPERATIONS_WITHOUT_FGA.keys()) { const operation = OPERATIONS.get(operationId); diff --git a/apps/cli/src/command-internal/config-pull-run.ts b/apps/cli/src/command-internal/config-pull-run.ts index 37851a5be3..20400f5763 100644 --- a/apps/cli/src/command-internal/config-pull-run.ts +++ b/apps/cli/src/command-internal/config-pull-run.ts @@ -25,6 +25,7 @@ import { decodeCliConfigDocumentForValidation, type CliConfigValidationOptions, } from "../config/cli-config-validation.ts"; +import { block } from "./command-permissions/model.ts"; import { sanitizeErrorBody, sanitizeInlineName } from "./http-errors.ts"; import type { ConfigTarget } from "./project-target.ts"; import { BRANCH_UUID_PATTERN } from "./ref-patterns.ts"; @@ -569,6 +570,11 @@ export const openConfigPullSource = Effect.fn("ConfigPull.openSource")(function* return { loaded, text: loaded.rawText }; }); +/** `planConfigPullRun`'s remote config read, shared by `config pull` and `pull`. */ +export const configPullRun = block("config-pull-run", [ + { operationId: "v2-get-project-config", kind: "required" }, +]); + /** * Resolves the pull destination, reloads with the `[remotes.*]` overlay when block reuse * selects an existing block, fetches the remote config, and runs the fixpoint-expanded diff --git a/apps/cli/src/command-internal/container-cli.ts b/apps/cli/src/command-internal/container-cli.ts index c8ccc780a1..05687938f6 100644 --- a/apps/cli/src/command-internal/container-cli.ts +++ b/apps/cli/src/command-internal/container-cli.ts @@ -296,29 +296,35 @@ function isDockerApiVersionAtLeast(version: string, minVersion: string): boolean } /** - * Checks whether the daemon's negotiated API version supports `docker volume prune --all` - * (added in 1.42). Older daemons reject the flag outright instead of ignoring it, so this must be - * checked via `docker version` before use; there's no persistent Engine API client to ask - * directly. Does not fall back to Podman, whose `volume prune` has no `--all` flag to gate. - * Resolves to `false` on any failure to spawn `docker` or read its version — the same effect as - * omitting `--all` on a pre-1.42 daemon, which already prunes every unused volume without it. + * Checks whether both the Docker client and daemon API versions are >= 1.42, the minimum for + * `docker volume prune --all`. The flag is client-side and validated against the negotiated + * version, so an old client against a new daemon still rejects it. Resolves to `false` on any + * failure to spawn `docker` or read its versions; omitting `--all` is the safe fallback. Does not + * fall back to Podman, whose `volume prune` has no `--all` flag to gate. */ export const dockerSupportsVolumePruneAllFlag = (spawner: Spawner) => Effect.scoped( Effect.gen(function* () { const child = yield* spawner.spawn( - ChildProcess.make("docker", ["version", "--format", "{{.Server.APIVersion}}"], { - stdin: "ignore", - stdout: "pipe", - stderr: "ignore", - }), + ChildProcess.make( + "docker", + ["version", "--format", "{{.Client.APIVersion}} {{.Server.APIVersion}}"], + { + stdin: "ignore", + stdout: "pipe", + stderr: "ignore", + }, + ), ); const [exitCode, stdout] = yield* Effect.all( [child.exitCode.pipe(Effect.map(Number)), collectText(child.stdout)], { concurrency: "unbounded" }, ); if (exitCode !== 0) return false; - const version = stdout.trim(); - return version.length > 0 && isDockerApiVersionAtLeast(version, "1.42"); + const versions = stdout.trim().split(/\s+/); + return ( + versions.length === 2 && + versions.every((version) => isDockerApiVersionAtLeast(version, "1.42")) + ); }), ).pipe(Effect.orElseSucceed(() => false)); diff --git a/apps/cli/src/command-internal/container-cli.unit.test.ts b/apps/cli/src/command-internal/container-cli.unit.test.ts index 64ed52331d..d65317e74c 100644 --- a/apps/cli/src/command-internal/container-cli.unit.test.ts +++ b/apps/cli/src/command-internal/container-cli.unit.test.ts @@ -17,6 +17,7 @@ function mockSpawner( readonly bothMissing?: boolean; readonly exitCode?: number; readonly stdout?: string; + readonly apiVersions?: { readonly client: string; readonly server: string }; } = {}, ) { const spawned: Array<{ readonly command: string; readonly args: ReadonlyArray }> = []; @@ -36,14 +37,21 @@ function mockSpawner( }); } + const stdout = + opts.apiVersions !== undefined && args[0] === "version" + ? (args[args.indexOf("--format") + 1] ?? "") + .replaceAll("{{.Client.APIVersion}}", opts.apiVersions.client) + .replaceAll("{{.Server.APIVersion}}", opts.apiVersions.server) + : opts.stdout; + const exitDeferred = yield* Deferred.make(); yield* Deferred.succeed(exitDeferred, ChildProcessSpawner.ExitCode(opts.exitCode ?? 0)); return ChildProcessSpawner.makeHandle({ pid: ChildProcessSpawner.ProcessId(1), stdout: - opts.stdout !== undefined - ? Stream.fromIterable([new TextEncoder().encode(opts.stdout)]) + stdout !== undefined + ? Stream.fromIterable([new TextEncoder().encode(stdout)]) : Stream.empty, stderr: Stream.empty, all: Stream.empty, @@ -222,29 +230,26 @@ describe("container CLI process spans", () => { }); describe("dockerSupportsVolumePruneAllFlag", () => { - it.live("returns true when the daemon reports an API version at or above 1.42", () => { - const mock = mockSpawner({ stdout: "1.42" }); - return dockerSupportsVolumePruneAllFlag(mock.spawner).pipe( - Effect.map((supportsAll) => { - expect(supportsAll).toBe(true); - expect(mock.spawned).toEqual([ - { command: "docker", args: ["version", "--format", "{{.Server.APIVersion}}"] }, - ]); - }), - ); - }); - - it.live("returns true for a version comfortably above 1.42", () => { - const mock = mockSpawner({ stdout: "1.51" }); - return dockerSupportsVolumePruneAllFlag(mock.spawner).pipe( - Effect.map((supportsAll) => { - expect(supportsAll).toBe(true); - }), - ); - }); + it.live( + "returns true when the client and daemon both report an API version at or above 1.42", + () => { + const mock = mockSpawner({ apiVersions: { client: "1.42", server: "1.51" } }); + return dockerSupportsVolumePruneAllFlag(mock.spawner).pipe( + Effect.map((supportsAll) => { + expect(supportsAll).toBe(true); + expect(mock.spawned).toEqual([ + { + command: "docker", + args: ["version", "--format", "{{.Client.APIVersion}} {{.Server.APIVersion}}"], + }, + ]); + }), + ); + }, + ); - it.live("returns false when the daemon reports an API version below 1.42", () => { - const mock = mockSpawner({ stdout: "1.41" }); + it.live("returns false for an old client against a new daemon", () => { + const mock = mockSpawner({ apiVersions: { client: "1.41", server: "1.44" } }); return dockerSupportsVolumePruneAllFlag(mock.spawner).pipe( Effect.map((supportsAll) => { expect(supportsAll).toBe(false); @@ -252,10 +257,20 @@ describe("dockerSupportsVolumePruneAllFlag", () => { ); }); - it.live("compares version components numerically, not lexicographically", () => { - // A naive string compare would misorder "1.9" as greater than "1.42" — this - // guards the numeric, component-by-component comparison instead. - const mock = mockSpawner({ stdout: "1.9" }); + it.live.each([ + { + name: "a new client against an old daemon", + options: { apiVersions: { client: "1.44", server: "1.41" } }, + }, + { + name: "versions that only compare above 1.42 lexicographically", + options: { apiVersions: { client: "1.9", server: "1.9" } }, + }, + { name: "a single version instead of a client and daemon pair", options: { stdout: "1.51" } }, + { name: "more than two versions", options: { stdout: "1.51 1.51 1.51" } }, + { name: "empty output", options: { stdout: "" } }, + ])("returns false for $name", ({ options }) => { + const mock = mockSpawner(options); return dockerSupportsVolumePruneAllFlag(mock.spawner).pipe( Effect.map((supportsAll) => { expect(supportsAll).toBe(false); @@ -264,16 +279,7 @@ describe("dockerSupportsVolumePruneAllFlag", () => { }); it.live("returns false when the version command exits non-zero", () => { - const mock = mockSpawner({ exitCode: 1, stdout: "1.51" }); - return dockerSupportsVolumePruneAllFlag(mock.spawner).pipe( - Effect.map((supportsAll) => { - expect(supportsAll).toBe(false); - }), - ); - }); - - it.live("returns false when the reported version is empty", () => { - const mock = mockSpawner({ stdout: "" }); + const mock = mockSpawner({ exitCode: 1, apiVersions: { client: "1.51", server: "1.51" } }); return dockerSupportsVolumePruneAllFlag(mock.spawner).pipe( Effect.map((supportsAll) => { expect(supportsAll).toBe(false); diff --git a/apps/cli/src/command-internal/db-config.layer.ts b/apps/cli/src/command-internal/db-config.layer.ts index 862cfb694a..9f7272f916 100644 --- a/apps/cli/src/command-internal/db-config.layer.ts +++ b/apps/cli/src/command-internal/db-config.layer.ts @@ -95,7 +95,7 @@ export const dbConfigLinked = block("db-config", linkedEntries); /** * `DbConfigResolver` on the linked path; `--local` and `--db-url` make no API calls. Commands * include it with `withDbTarget`. The container retry that `db dump` and `db pull` run is - * {@link dbConfigPoolerFallback}; `gen types --project-id` fetches the + * {@link dbConfigPoolerFallback}; `gen types` fetches the * pooler config without `--project-ref`, so that command declares its own entry. */ export const dbConfig = block( @@ -106,38 +106,54 @@ export const dbConfig = block( })), ); -/** - * `DbConfigResolver.resolvePoolerFallback`, the pooler retry after a linked dump, declarative export - * or pull diff fails with an IPv6 connectivity error against a direct host the CLI could reach. - */ -export const dbConfigPoolerFallback = block("db-config-pooler-fallback", [ +const poolerFallbackEntries: ReadonlyArray> = [ { operationId: "v1-get-pooler-config", kind: "required", - when: [{ dbTarget: ["linked"] }], context: - "a linked dump, declarative export or pull diff fails with an IPv6 connectivity error against the direct database host, and there is no usable saved supabase/.temp/pooler-url", + "the command's database work fails with an IPv6 connectivity error against the direct database host, and there is no usable saved supabase/.temp/pooler-url", }, { operationId: "v1-create-login-role", kind: "required", - when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + when: [{ flag: "password", present: false }], context: - "the same retry, and no database password supplied by the command (an empty one still mints a login role) or set in SUPABASE_DB_PASSWORD or the project .env", + "the same retry, and no database password supplied by the command (an empty one still mints a login role) or set in SUPABASE_DB_PASSWORD or the project .env (an ad-hoc project ref ignores the last two)", }, { operationId: "v1-list-all-network-bans", kind: "best-effort", - when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + when: [{ flag: "password", present: false }], context: "the same retry, after three failed attempts to connect as the temporary login role", }, { operationId: "v1-delete-network-bans", kind: "best-effort", - when: [{ dbTarget: ["linked"] }, { flag: "password", present: false }], + when: [{ flag: "password", present: false }], context: "the same retry, after three failed attempts to connect as the temporary login role", }, -]); +]; + +/** + * The same calls as {@link dbConfigPoolerFallback} without its target condition, for `gen types`, + * which picks its target from flag values. + */ +export const dbConfigPoolerFallbackLinked = block( + "db-config-pooler-fallback", + poolerFallbackEntries, +); + +/** + * `DbConfigResolver.resolvePoolerFallback`, the pooler retry after a linked dump, declarative export + * or pull diff fails with an IPv6 connectivity error against a direct host the CLI could reach. + */ +export const dbConfigPoolerFallback = block( + "db-config-pooler-fallback", + poolerFallbackEntries.map((entry) => ({ + ...entry, + when: [{ dbTarget: ["linked" as const] }, ...(entry.when ?? [])], + })), +); const DIRECT_PORT = 5432; const TCP_PROBE_TIMEOUT = Duration.seconds(5); diff --git a/apps/cli/src/command-internal/docker-remove-all.ts b/apps/cli/src/command-internal/docker-remove-all.ts index 70f92f4f5a..836cc046f3 100644 --- a/apps/cli/src/command-internal/docker-remove-all.ts +++ b/apps/cli/src/command-internal/docker-remove-all.ts @@ -213,7 +213,7 @@ export const dockerRemoveAll = ( if (deleteVolumes) { // `--all` requires Docker API >= 1.42 and is validated by the Docker CLI itself, so passing - // it unconditionally would hard-fail on an older daemon; ask `docker version` via + // it unconditionally would hard-fail on an older client or daemon; ask `docker version` via // {@link dockerSupportsVolumePruneAllFlag} instead of negotiating the API version directly. // Podman has no `--all` flag on `volume prune` at all, but already prunes every unused // volume by default, so omitting `--all` there is a lossless fallback. diff --git a/apps/cli/src/command-internal/http-errors.ts b/apps/cli/src/command-internal/http-errors.ts index 0bb77c8c60..eb3f93a9c5 100644 --- a/apps/cli/src/command-internal/http-errors.ts +++ b/apps/cli/src/command-internal/http-errors.ts @@ -19,8 +19,8 @@ const MAX_BODY_LEN = 1024; /** * Truncates and sanitizes a response body for inclusion in an error message. - * Shared by handlers that bypass the typed client (e.g. `sso add`/`sso update` - * raw-HTTP POST/PUT) and `mapHttpError`, so both apply the same defense-in-depth. + * Shared by handlers that read raw response bodies (`executeRaw` callers) and + * `mapHttpError`, so both apply the same defense-in-depth. */ export function sanitizeErrorBody(input: string): string { const capped = input.length > MAX_BODY_LEN ? input.slice(0, MAX_BODY_LEN) : input; diff --git a/apps/cli/src/command-internal/login-api.layer.integration.test.ts b/apps/cli/src/command-internal/login-api.layer.integration.test.ts index c2d27e9cd2..6e7da75165 100644 --- a/apps/cli/src/command-internal/login-api.layer.integration.test.ts +++ b/apps/cli/src/command-internal/login-api.layer.integration.test.ts @@ -11,15 +11,20 @@ import { import { assertBlockDrift } from "../../tests/helpers/permission-drift.ts"; import { loginApiLayer, loginProfile } from "./login-api.layer.ts"; +const PROFILE = { gotrue_id: "gotrue-user-123", primary_email: "a@b.co", username: "ab" }; + +function setup(opts: Parameters[0]) { + const api = mockCommandPlatformApi(opts); + const layer = loginApiLayer.pipe( + Layer.provide(api.httpClientLayer), + Layer.provide(mockCommandSettings({ workdir: "/unused-workdir" })), + ); + return { api, layer }; +} + describe("loginApiLayer", () => { it.effect("fetches the account identity from GET /v1/profile after login", () => { - const api = mockCommandPlatformApi({ - response: { status: 200, body: { gotrue_id: "gotrue-user-123" } }, - }); - const layer = loginApiLayer.pipe( - Layer.provide(api.httpClientLayer), - Layer.provide(mockCommandSettings({ workdir: "/unused-workdir" })), - ); + const { api, layer } = setup({ response: { status: 200, body: PROFILE } }); return Effect.gen(function* () { const loginApi = yield* LoginApi; const gotrueId = yield* loginApi.fetchGotrueId(DEFAULT_API_URL, VALID_TOKEN); @@ -34,14 +39,19 @@ describe("loginApiLayer", () => { }).pipe(Effect.provide(layer)); }); + it.effect("sends the profile lookup to the given host with the fresh token", () => { + const { api, layer } = setup({ response: { status: 200, body: PROFILE } }); + return Effect.gen(function* () { + const loginApi = yield* LoginApi; + yield* loginApi.fetchGotrueId("https://api.fresh.example", "sbp_fresh_token"); + expect(api.requests).toHaveLength(1); + expect(api.requests[0]?.url).toBe("https://api.fresh.example/v1/profile"); + expect(api.requests[0]?.headers["authorization"]).toBe("Bearer sbp_fresh_token"); + }).pipe(Effect.provide(layer)); + }); + it.effect("skips the identity stitch when the profile lookup is forbidden", () => { - const api = mockCommandPlatformApi({ - response: { status: 403, body: { message: "Forbidden" } }, - }); - const layer = loginApiLayer.pipe( - Layer.provide(api.httpClientLayer), - Layer.provide(mockCommandSettings({ workdir: "/unused-workdir" })), - ); + const { api, layer } = setup({ response: { status: 403, body: { message: "Forbidden" } } }); return Effect.gen(function* () { const loginApi = yield* LoginApi; const gotrueId = yield* loginApi.fetchGotrueId(DEFAULT_API_URL, VALID_TOKEN); diff --git a/apps/cli/src/command-internal/login-api.layer.ts b/apps/cli/src/command-internal/login-api.layer.ts index 2eb81c228d..14c78dbe13 100644 --- a/apps/cli/src/command-internal/login-api.layer.ts +++ b/apps/cli/src/command-internal/login-api.layer.ts @@ -1,3 +1,4 @@ +import { makeApiClient } from "@supabase/api/effect"; import { Effect, Layer, Option } from "effect"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; @@ -35,7 +36,8 @@ export const loginApiLayer = Layer.effect( return LoginApi.of({ fetchLoginSession: (apiHost: string, sessionId: string, deviceCode: string) => Effect.gen(function* () { - const url = `${apiHost}/platform/cli/login/${sessionId}?device_code=${deviceCode}`; + // oxlint-disable-next-line supabase/no-raw-management-api-url -- not in the Management API OpenAPI spec, so no typed operation exists + const url = `${apiHost}/platform/cli/login/${encodeURIComponent(sessionId)}?device_code=${encodeURIComponent(deviceCode)}`; const request = HttpClientRequest.get(url).pipe( HttpClientRequest.setHeader("User-Agent", cliSettings.userAgent), ); @@ -82,18 +84,23 @@ export const loginApiLayer = Layer.effect( }), ), + // No client is configured yet at login (the token was just obtained), so build one for this + // host and token. fetchGotrueId: (apiHost: string, token: string) => Effect.gen(function* () { - const request = HttpClientRequest.get(`${apiHost}/v1/profile`).pipe( - HttpClientRequest.setHeader("Authorization", `Bearer ${token}`), - HttpClientRequest.setHeader("User-Agent", cliSettings.userAgent), - ); - const response = yield* httpClient.execute(request); - if (response.status !== 200) return Option.none(); - const body = yield* response.json; - const gotrueId = readString(body, "gotrue_id"); - return gotrueId.length > 0 ? Option.some(gotrueId) : Option.none(); - }).pipe(Effect.orElseSucceed(() => Option.none())), + const api = yield* makeApiClient({ + baseUrl: apiHost, + accessToken: token, + userAgent: cliSettings.userAgent, + }); + const profile = yield* api.v1.getProfile(); + return profile.gotrue_id.length > 0 + ? Option.some(profile.gotrue_id) + : Option.none(); + }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.orElseSucceed(() => Option.none()), + ), }); }), ); diff --git a/apps/cli/src/command-internal/organization-picker.ts b/apps/cli/src/command-internal/organization-picker.ts new file mode 100644 index 0000000000..0d9c25e19b --- /dev/null +++ b/apps/cli/src/command-internal/organization-picker.ts @@ -0,0 +1,10 @@ +import { block } from "./command-permissions/model.ts"; + +/** The organization listing made when no organization ID is given, shared by `projects create` and `bootstrap`. */ +export const organizationPicker = block("organization-picker", [ + { + operationId: "v1-list-all-organizations", + kind: "required", + when: [{ flag: "org-id", present: false }], + }, +]); diff --git a/apps/cli/src/command-internal/output.encoders.ts b/apps/cli/src/command-internal/output.encoders.ts index 4bef8f0e48..c7adfb14ec 100644 --- a/apps/cli/src/command-internal/output.encoders.ts +++ b/apps/cli/src/command-internal/output.encoders.ts @@ -61,9 +61,7 @@ function sortKeysDeep(value: unknown): unknown { * Serializes an outbound API request body with sorted keys, HTML-safe string escaping, no * indentation, and no trailing newline. * - * Used on the raw-HTTP code path (`sso add`/`sso update`) whose request bodies the cli-e2e replay - * server compares by string equality against recorded bodies, so key order and escaping must - * match exactly. {@link encodeSortedJson} is the parallel for human-facing `--output json`. + * {@link encodeSortedJson} is the parallel for human-facing `--output json`. */ export function encodeSortedJsonBody(value: unknown): string { return encodeHtmlSafeJsonCompact(sortKeysDeep(value)); diff --git a/apps/cli/src/command-internal/project-create-core.ts b/apps/cli/src/command-internal/project-create-core.ts index 1dd095f5f1..2c79ff59c6 100644 --- a/apps/cli/src/command-internal/project-create-core.ts +++ b/apps/cli/src/command-internal/project-create-core.ts @@ -127,8 +127,8 @@ export const projectCreateCore = Effect.fn("ProjectCreate.run")(function* ( const creating = output.format === "text" ? yield* output.task("Creating project...") : undefined; - // `executeRaw` skips output decoding: the 201 response's `ref` can be the cli-e2e - // `__PROJECT_REF__` placeholder, which the generated schema would reject. + // `executeRaw` skips output decoding so the created project is emitted as the API returned it, + // including fields the generated schema does not declare. const response = yield* api.executeRaw(operationDefinitions.v1CreateAProject, body).pipe( Effect.tapError(() => creating?.fail() ?? Effect.void), Effect.mapError( diff --git a/apps/cli/src/command-internal/project-target.ts b/apps/cli/src/command-internal/project-target.ts index 1dac052d46..9505cc40b3 100644 --- a/apps/cli/src/command-internal/project-target.ts +++ b/apps/cli/src/command-internal/project-target.ts @@ -10,7 +10,9 @@ import { resolveParentScopedProjectRef, } from "./parent-project-ref.ts"; import { Output } from "../shared/output/output.service.ts"; -import { resolveBranchProjectRef } from "./branch-ref.resolver.ts"; +import { projectRefPicker } from "../config/project-ref.layer.ts"; +import { branchRef, resolveBranchProjectRef } from "./branch-ref.resolver.ts"; +import { compose, withCondition } from "./command-permissions/model.ts"; import { sanitizeInlineName } from "./http-errors.ts"; import { BRANCH_PROJECT_REF_PATTERN, BRANCH_UUID_PATTERN } from "./ref-patterns.ts"; import { @@ -148,6 +150,12 @@ function reclassifyBranchNotFoundError( return cause.status === 404 ? Effect.fail(notFoundError) : Effect.fail(cause); } +/** The calls `resolveConfigTarget` makes: a branch lookup for a non-ref `--project-ref`, the project picker when no ref is given or linked. */ +export const configTarget = compose( + withCondition(branchRef, { flag: "project-ref", present: true }), + projectRefPicker, +); + /** * Resolves `--project-ref` to a {@link ConfigTarget}: a project ref, or the name/UUID of a * branch on the linked project. A ref-shaped value (20 lowercase letters) is always a project ref. diff --git a/apps/cli/src/command-internal/raw-http.ts b/apps/cli/src/command-internal/raw-http.ts deleted file mode 100644 index 7d30d2c998..0000000000 --- a/apps/cli/src/command-internal/raw-http.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { Option, type Redacted } from "effect"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; - -/** - * Applies the Management API auth + identification headers to a raw `HttpClientRequest`: - * an `Authorization: Bearer` header when a token is present, and the CLI `User-Agent`. - * - * Shared by commands that bypass the typed Management API client and issue raw HTTP - * (e.g. `postgres-config`, `config push`'s cost-matrix fetch). - */ -export function requestWithAuth( - request: HttpClientRequest.HttpClientRequest, - tokenOpt: Option.Option>, - userAgent: string, -): HttpClientRequest.HttpClientRequest { - return request.pipe( - Option.isSome(tokenOpt) ? HttpClientRequest.bearerToken(tokenOpt.value) : (req) => req, - HttpClientRequest.setHeader("User-Agent", userAgent), - ); -} diff --git a/apps/cli/src/command-internal/upgrade-suggest.ts b/apps/cli/src/command-internal/upgrade-suggest.ts index e927d5dbcc..e45dfec831 100644 --- a/apps/cli/src/command-internal/upgrade-suggest.ts +++ b/apps/cli/src/command-internal/upgrade-suggest.ts @@ -2,13 +2,11 @@ import { styleText } from "node:util"; import type { SupabaseApiError } from "@supabase/api/effect"; import { Effect, Option, type Redacted } from "effect"; -import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientError from "effect/http/HttpClientError"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; import type * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { CommandPlatformApi } from "../auth/command-platform-api.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; -import { resolveAccessToken } from "./resolve-token.ts"; import { Analytics } from "../shared/telemetry/analytics.service.ts"; import { EventUpgradeSuggested, @@ -92,10 +90,7 @@ export const gateMapError = * Suggests an upgrade when a request fails with a plan-gate error. Never * fails the caller. * - * The fallback bypasses the typed API client, whose strict response schemas - * reject the cli-e2e replay fixtures' placeholder refs (same workaround as - * `linked-project-cache.layer.ts`). Returns whether the feature was - * confirmed plan-gated. + * Returns whether the feature was confirmed plan-gated. */ export const suggestUpgrade = Effect.fn("UpgradeSuggest.check")(function* (opts: { readonly projectRef: string; @@ -118,8 +113,8 @@ export const suggestUpgrade = Effect.fn("UpgradeSuggest.check")(function* (opts: * Overrides the bearer token of the fallback GETs, complementing `apiUrl`: * a caller passing a reconciled `apiUrl` must also pass that profile's * token, or the stale profile's token would reach the reconciled host. - * `Some` uses that token, `None` sends unauthenticated, `undefined` - * resolves from the service. + * `Some` uses that token, `None` skips the fallback (the client cannot send + * an unauthenticated request), `undefined` uses the client's own token. */ readonly accessToken?: Option.Option>; /** Set false for call sites that must not fire `EventUpgradeSuggested` (vanity check-availability). */ @@ -132,7 +127,6 @@ export const suggestUpgrade = Effect.fn("UpgradeSuggest.check")(function* (opts: const output = yield* Output; const analytics = yield* Analytics; const cliSettings = yield* CommandSettings; - const httpClient = yield* HttpClient.HttpClient; yield* Effect.annotateCurrentSpan("upgrade.gated", false); let gate: @@ -156,60 +150,36 @@ export const suggestUpgrade = Effect.fn("UpgradeSuggest.check")(function* (opts: return false; } - const tokenOpt = - opts.accessToken ?? - (yield* resolveAccessToken.pipe( - Effect.catchTag("PlatformError", () => Effect.succeed(Option.none())), - )); - const authHeader: ( - req: HttpClientRequest.HttpClientRequest, - ) => HttpClientRequest.HttpClientRequest = Option.isSome(tokenOpt) - ? HttpClientRequest.bearerToken(tokenOpt.value) - : (req) => req; - - const apiUrl = opts.apiUrl ?? cliSettings.apiUrl; - const projectReq = HttpClientRequest.get(`${apiUrl}/v1/projects/${opts.projectRef}`).pipe( - authHeader, - HttpClientRequest.setHeader("User-Agent", cliSettings.userAgent), - ); - const projectResp = yield* httpClient.execute(projectReq).pipe(Effect.option); - if (projectResp._tag === "None" || projectResp.value.status !== 200) { + if (opts.accessToken !== undefined && Option.isNone(opts.accessToken)) { return false; } - const projectBody = yield* projectResp.value.json.pipe(Effect.option); - if (projectBody._tag === "None") { + + 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 = readString(projectBody.value, "organization_slug"); + const orgSlug = project.value.organization_slug; if (orgSlug.length === 0) { return false; } - const entReq = HttpClientRequest.get(`${apiUrl}/v1/organizations/${orgSlug}/entitlements`).pipe( - authHeader, - HttpClientRequest.setHeader("User-Agent", cliSettings.userAgent), - ); - const entResp = yield* httpClient.execute(entReq).pipe(Effect.option); - if (entResp._tag === "None" || entResp.value.status !== 200) { - return false; - } - const entBody = yield* entResp.value.json.pipe(Effect.option); - if (entBody._tag === "None") { - return false; - } - const entitlements = (entBody.value as { entitlements?: unknown }).entitlements; - if (!Array.isArray(entitlements)) { + const entitlements = yield* api.v1 + .getOrganizationEntitlements({ slug: orgSlug }, requestOptions) + .pipe(Effect.option); + if (Option.isNone(entitlements)) { return false; } - const gated = entitlements.some((entry: unknown) => { - if (typeof entry !== "object" || entry === null) return false; - const feature = (entry as { feature?: unknown }).feature; - if (typeof feature !== "object" || feature === null) return false; - const key = (feature as { key?: unknown }).key; - const hasAccess = (entry as { hasAccess?: unknown }).hasAccess; - return key === opts.featureKey && hasAccess === false; - }); + const gated = entitlements.value.entitlements.some( + (entry) => entry.feature.key === opts.featureKey && entry.hasAccess === false, + ); if (!gated) { return false; } diff --git a/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts b/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts index d15908cd2a..4da09f494e 100644 --- a/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts +++ b/apps/cli/src/command-internal/upgrade-suggest.unit.test.ts @@ -349,19 +349,38 @@ describe("suggestUpgrade", () => { }).pipe(Effect.provide(layer)); }); - it.live("a reconciled profile with no token sends the fallback GETs unauthenticated", () => { + it.live( + "a reconciled profile with no token skips the fallback instead of reusing another token", + () => { + const { layer, api, analytics } = setup(); + return Effect.gen(function* () { + const suggested = yield* suggestUpgrade({ + projectRef: VALID_REF, + featureKey: "branching_limit", + statusCode: 402, + accessToken: Option.none(), + }); + expect(suggested).toBe(false); + expect(api.requests).toHaveLength(0); + expect(analytics.captured).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }, + ); + + it.live("a caller-provided apiUrl routes the fallback GETs to that host", () => { const { layer, api } = setup(); return Effect.gen(function* () { yield* suggestUpgrade({ projectRef: VALID_REF, featureKey: "branching_limit", statusCode: 402, - accessToken: Option.none(), + apiUrl: "https://api.reconciled.example", + accessToken: Option.some(Redacted.make("sbp_reconciled_token")), }); - expect(api.requests).toHaveLength(2); - for (const req of api.requests) { - expect(req.headers["authorization"]).toBeUndefined(); - } + expect(api.requests.map((req) => new URL(req.url).origin)).toEqual([ + "https://api.reconciled.example", + "https://api.reconciled.example", + ]); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md index e164ec913d..b26a98fb83 100644 --- a/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/list/SIDE_EFFECTS.md @@ -12,9 +12,10 @@ ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after the command runs (in `Effect.ensuring`), unless the file already exists, `.temp/project-ref` names a different project, no access token resolves, or the project lookup is not 200 | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes @@ -22,6 +23,7 @@ | ------ | ------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/v1/projects/{ref}/database/backups` | Bearer token | none | `{region, walg_enabled, pitr_enabled, backups: [{inserted_at, status, is_physical_backup}], physical_backup_data: {earliest_physical_backup_date_unix?, latest_physical_backup_date_unix?}}` | | `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, organization_slug, region, ...}]` — TTY-prompt fallback only | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | ## Environment Variables diff --git a/apps/cli/src/commands/backups/list/list.command.ts b/apps/cli/src/commands/backups/list/list.command.ts index 069efa6a50..942b76f10c 100644 --- a/apps/cli/src/commands/backups/list/list.command.ts +++ b/apps/cli/src/commands/backups/list/list.command.ts @@ -1,6 +1,10 @@ import type * as CliCommand from "effect/cli/Command"; import { Command, Flag } from "effect/cli"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -28,6 +32,13 @@ export const backupsListCommand = Command.make("list", config).pipe( description: "List backups for a specific project", }, ]), + withPermissions( + compose( + { operations: [{ operationId: "v1-list-all-backups", kind: "required" }] }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => backupsList(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/backups/list/list.integration.test.ts b/apps/cli/src/commands/backups/list/list.integration.test.ts index d4e1b73c2d..2ddc67d6da 100644 --- a/apps/cli/src/commands/backups/list/list.integration.test.ts +++ b/apps/cli/src/commands/backups/list/list.integration.test.ts @@ -1,18 +1,40 @@ -import { type V1ListAllBackupsOutput } from "@supabase/api/effect"; +import { type V1ListAllBackupsOutput, type V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Path } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Path, Schema } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockCommandPlatformApi, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { backupsList } from "./list.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + const PITR_RESPONSE: typeof V1ListAllBackupsOutput.Type = { region: "ap-southeast-1", walg_enabled: true, @@ -214,6 +236,7 @@ WalgEnabled = true yield* backupsList({ projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/database/backups`); + assertPermissionDrift({ command: "backups list", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -223,9 +246,63 @@ WalgEnabled = true return Effect.gen(function* () { yield* backupsList({ projectRef: Option.some(flagRef) }); expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); + assertPermissionDrift({ + command: "backups list", + activeFlags: { "project-ref": true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); + it.effect( + "lists projects for the picker, then the backups, then fills the linked-project cache", + () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [PROJECT])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, PROJECT)); + } + return Effect.succeed(jsonResponse(request, 200, PITR_RESPONSE)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* backupsList({ projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/database/backups`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain("Southeast Asia (Singapore)"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "backups list", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("reads supabase/.temp/project-ref when env and flag are unset", () => { const fileRef = "filerefabcdefghijklm"; const out = mockOutput({ format: "text" }); diff --git a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md index 9592f72391..5f1c15cba3 100644 --- a/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/backups/restore/SIDE_EFFECTS.md @@ -12,16 +12,18 @@ ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after the command runs (in `Effect.ensuring`), unless the file already exists, `.temp/project-ref` names a different project, no access token resolves, or the project lookup is not 200 | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------------------------------------------- | ------------ | ------------------------------------ | ------------------------------------------------------------------------------ | -| `POST` | `/v1/projects/{ref}/database/backups/restore-pitr` | Bearer token | `{recovery_time_target_unix: int64}` | none (201 Created) | -| `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, organization_slug, region, ...}]` — TTY-prompt fallback only | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------------------------- | ------------ | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------- | +| `POST` | `/v1/projects/{ref}/database/backups/restore-pitr` | Bearer token | `{recovery_time_target_unix: int64}` | none (201 Created) | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, ref, name, organization_slug, region, ...}]` — TTY-prompt fallback only | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | ## Environment Variables diff --git a/apps/cli/src/commands/backups/restore/restore.command.ts b/apps/cli/src/commands/backups/restore/restore.command.ts index d15aed8db9..5a51d25c68 100644 --- a/apps/cli/src/commands/backups/restore/restore.command.ts +++ b/apps/cli/src/commands/backups/restore/restore.command.ts @@ -1,6 +1,10 @@ import type * as CliCommand from "effect/cli/Command"; import { Command, Flag } from "effect/cli"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -29,6 +33,16 @@ export const backupsRestoreCommand = Command.make("restore", config).pipe( description: "Restore to the given Unix epoch timestamp", }, ]), + withPermissions( + compose( + { + operations: [{ operationId: "v1-restore-pitr-backup", kind: "required" }], + noApiEffectFlags: ["timestamp"], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => backupsRestore(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/backups/restore/restore.integration.test.ts b/apps/cli/src/commands/backups/restore/restore.integration.test.ts index d3d6f44629..adae0d4ce6 100644 --- a/apps/cli/src/commands/backups/restore/restore.integration.test.ts +++ b/apps/cli/src/commands/backups/restore/restore.integration.test.ts @@ -1,6 +1,6 @@ +import type { V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; -import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { @@ -11,8 +11,29 @@ import { mockCommandPlatformApi, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { backupsRestore } from "./restore.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org_123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-01-01T00:00:00Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.example.com", + version: "15.0", + postgres_engine: "supabase-postgres", + release_channel: "ga", + }, +}; + interface SetupOpts { format?: "text" | "json" | "stream-json"; outputFlag?: "env" | "pretty" | "json" | "toml" | "yaml"; @@ -49,6 +70,7 @@ describe("backups restore integration", () => { }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.body).toEqual({ recovery_time_target_unix: 0 }); + assertPermissionDrift({ command: "backups restore", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -60,6 +82,12 @@ describe("backups restore integration", () => { timestamp: Option.some(1_707_407_047), }); expect(api.requests[0]?.body).toEqual({ recovery_time_target_unix: 1_707_407_047 }); + assertPermissionDrift({ + command: "backups restore", + activeFlags: { timestamp: true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -137,6 +165,12 @@ describe("backups restore integration", () => { timestamp: Option.none(), }); expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/`); + assertPermissionDrift({ + command: "backups restore", + activeFlags: { "project-ref": true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -192,64 +226,61 @@ describe("backups restore integration", () => { }); }); - it.effect("prompts via TTY when no ref source matches and stdin is a TTY", () => { - const out = mockOutput({ - format: "text", - promptSelectResponses: [VALID_REF], - }); - // Branches on path: the resolver's list call gets a project array, the restore call gets 201. - const api = mockCommandPlatformApi({ - handler: (request) => { - if (request.url.includes("/v1/projects") && !request.url.includes("/database/backups")) { + it.effect( + "prompts via TTY, restores, and fills the linked-project cache through the real layer", + () => { + const out = mockOutput({ + format: "text", + promptSelectResponses: [VALID_REF], + }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.method === "POST") { + return Effect.succeed(jsonResponse(request, 201, null)); + } return Effect.succeed( - HttpClientResponse.fromWeb( + jsonResponse( request, - new Response( - JSON.stringify([ - { - id: VALID_REF, - ref: VALID_REF, - organization_id: "org_123", - organization_slug: "acme", - name: "alpha", - region: "us-east-1", - created_at: "2026-01-01T00:00:00Z", - status: "ACTIVE_HEALTHY", - database: { - host: "db.example.com", - version: "15.0", - postgres_engine: "supabase-postgres", - release_channel: "ga", - }, - }, - ]), - { status: 200, headers: { "content-type": "application/json" } }, - ), + 200, + request.url === "https://api.supabase.com/v1/projects" ? [PROJECT] : PROJECT, ), ); - } - return Effect.succeed(jsonResponse(request, 201, null)); - }, - }); - const cliSettings = mockCommandSettings({ - workdir: tempRoot.current, - projectId: Option.none(), - }); - const layer = buildTestRuntime({ - out, - api, - cliSettings, - tty: mockTty({ stdinIsTty: true, stdoutIsTty: true }), - }); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true, stdoutIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); - return Effect.gen(function* () { - yield* backupsRestore({ projectRef: Option.none(), timestamp: Option.none() }).pipe( - Effect.provide(layer), - ); - expect(out.promptSelectCalls).toHaveLength(1); - expect(out.stderrText).toContain(`Started PITR restore: ${VALID_REF}\n`); - }); - }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* backupsRestore({ projectRef: Option.none(), timestamp: Option.none() }); + expect(out.promptSelectCalls).toHaveLength(1); + expect(out.stderrText).toContain(`Started PITR restore: ${VALID_REF}\n`); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `POST https://api.supabase.com/v1/projects/${VALID_REF}/database/backups/restore-pitr`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "backups restore", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); it.effect("accepts --timestamp short alias -t in the same way (no separate parse path)", () => { const { layer, api } = setup(); diff --git a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md index 4e42e962a8..8116b7f543 100644 --- a/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/bootstrap/SIDE_EFFECTS.md @@ -41,16 +41,17 @@ staying in effect. ## API Routes -| Method | Path | Auth | Notes | -| --------------- | ----------------------------------------------------------------------------------------- | ------------------------------ | -------------------------------------------------------------------------------------------- | -| `GET` | `api.github.com/repos/supabase-community/supabase-samples/contents/samples.json?ref=main` | optional `Bearer GITHUB_TOKEN` | base64 `content` → `{samples:[…]}` | -| `GET` | `api.github.com/repos///contents/?ref=` + raw `download_url` | optional `Bearer GITHUB_TOKEN` | template download (BFS, concurrency 5) | -| `GET` | `/v1/organizations` | Bearer | interactive org picker (create core) | -| `POST` | `/v1/projects` | Bearer | `{name, organization_slug, db_pass, region?, desired_instance_size?, template_url?}` → `201` | -| `GET` | `/v1/projects/{ref}/api-keys` | Bearer | retried with exponential backoff (no `reveal`) | -| `GET` | `/v1/projects/{ref}` + storage/pooler config + tenant version probes | Bearer / service key | `link.LinkServices` (best-effort) | -| `GET` | `/v1/projects/{ref}/health?services=db` | Bearer | retried with exponential backoff | -| login endpoints | — | — | ensure-login browser flow (token miss) | +| Method | Path | Auth | Notes | +| --------------- | -------------------------------------------------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------ | +| `GET` | `api.github.com/repos/supabase-community/supabase-samples/contents/samples.json?ref=main` | optional `Bearer GITHUB_TOKEN` | base64 `content` → `{samples:[…]}` | +| `GET` | `api.github.com/repos///contents/?ref=` + raw `download_url` | optional `Bearer GITHUB_TOKEN` | template download (BFS, concurrency 5) | +| `GET` | `/v1/organizations` | Bearer | organization listing, always made before the org prompt (bootstrap passes no org ID) | +| `POST` | `/v1/projects` | Bearer | `{name, organization_slug, db_pass, region, template_url?}` → `201` | +| `GET` | `/v1/projects/{ref}/api-keys` | Bearer | retried with exponential backoff (no `reveal`) | +| `GET` | `/v1/projects/{ref}/config/storage`, `/v1/projects/{ref}/config/database/pooler` + tenant version probes | Bearer / anon key (tenant probes) | `link.LinkServices` (best-effort) | +| `GET` | `/v1/projects/{ref}` | Bearer | linked-project cache fill (`Effect.ensuring`, best-effort) | +| `GET` | `/v1/projects/{ref}/health?services=db` | Bearer | retried with exponential backoff | +| login endpoints | — | — | ensure-login browser flow (token miss) | The native push step fires **no Management API routes of its own**. Its connection is resolved separately from (not reused from) the naive one written to `.env` above: `resolveLinkedConn` @@ -83,8 +84,9 @@ neither branch ever reaches the temp-login-role/Management-API path a passwordle - `cli_command_executed` — once (via `withCommandTelemetry`). - `cli_login_completed` — once, **only** on the browser-login path (token miss). - **No `cli_project_linked`** — bootstrap only links services (not a full `link` run), so it - deliberately skips the project-linked telemetry, status check, and the `linked-project.json` - temp write that the standalone `link` command performs. + deliberately skips the project-linked telemetry and the status check that the standalone `link` + command performs. Its `linked-project.json` comes only from the post-run cache fill (see Files + Written), not from a link step. - `create` fires no custom event. - The native push step (`dbPushCore`) fires **no telemetry of its own** — it is the bare handler function, not `db push`'s own `push.command.ts` wrapper (the thing that fires diff --git a/apps/cli/src/commands/bootstrap/bootstrap.command.ts b/apps/cli/src/commands/bootstrap/bootstrap.command.ts index 69bd8236e3..e709055b65 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.command.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.command.ts @@ -2,6 +2,12 @@ import { Layer } from "effect"; import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../command-internal/command-permissions/model.ts"; +import { projectApiKeys } from "../../command-internal/get-api-keys.ts"; +import { linkServices } from "../../command-internal/link-services-core.ts"; +import { linkedProjectCache } from "../../telemetry/linked-project-cache.layer.ts"; +import { organizationPicker } from "../../command-internal/organization-picker.ts"; import { cliConfigValuesLayer } from "../../config/cli-config-values.layer.ts"; import { withCliConfigFlags } from "../../config/cli-config-flags.ts"; import { CliConfigKeys } from "../../config/cli-config-keys.ts"; @@ -28,6 +34,27 @@ export type BootstrapFlags = CliCommand.Command.Config.Infer; export const bootstrapCommand = Command.make("bootstrap", config).pipe( Command.withDescription("Bootstrap a Supabase project from a starter template."), Command.withShortDescription("Bootstrap a Supabase project from a starter template"), + withPermissions( + compose( + { + operations: [ + { operationId: "v1-create-a-project", kind: "required" }, + { operationId: "v1-get-services-health", kind: "required" }, + { + operationId: "v1-get-pooler-config", + kind: "required", + context: + "the new project's direct database host is unreachable (IPv4-only network): the migration push connects through the pooler URL this call saves, and without it fails", + }, + ], + noApiEffectFlags: ["password"], + }, + organizationPicker, + projectApiKeys, + linkServices, + linkedProjectCache, + ), + ), Command.withHandler((flags) => // No bootstrap flag is telemetry-safe, so no `safeFlags`. bootstrap(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), diff --git a/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts b/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts index e6c90d1f07..854fdb7914 100644 --- a/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts +++ b/apps/cli/src/commands/bootstrap/bootstrap.workdir-cache.integration.test.ts @@ -33,6 +33,7 @@ import { } from "../../command-internal/global-flags.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { SuccessTrailer, successTrailerLayer } from "../../shared/cli/success-trailer.ts"; +import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; import { DbConnection, type PgConnInput } from "../../command-internal/db-connection.service.ts"; import { debugLoggerLayer } from "../../command-internal/debug-logger.layer.ts"; import { identityStitchLayer } from "../../command-internal/identity-stitch.ts"; @@ -43,6 +44,7 @@ import { CliConfigFlagInputs, makeCliConfigFlagInputs } from "../../config/cli-c import { cliConfigValuesLayer } from "../../config/cli-config-values.layer.ts"; import { TemplateService } from "./bootstrap.templates.ts"; import { bootstrap } from "./bootstrap.handler.ts"; +import type { V1GetProjectOutput } from "@supabase/api/effect"; import type { BootstrapFlags } from "./bootstrap.command.ts"; const FAST_BACKOFF = Schedule.exponential("1 milli"); @@ -56,6 +58,23 @@ const PROJECT = { region: "us-east-1", status: "COMING_UP", }; +// Schema-valid `V1GetProjectOutput`: the linked-project cache decodes it with the typed client. +const PROJECT_DETAIL: typeof V1GetProjectOutput.Type = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-1", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-01-01T00:00:00Z", + status: "COMING_UP", + database: { + host: "db.example.com", + version: "15", + postgres_engine: "15", + release_channel: "ga", + }, +}; const ORGS = [{ id: "org-1", slug: "acme", name: "Acme Inc" }]; const API_KEYS = [{ name: "anon", api_key: "anon-key" }]; const HEALTHY = [{ name: "db", healthy: true, status: "ACTIVE_HEALTHY" }]; @@ -133,7 +152,7 @@ describe("bootstrap linked-project cache location", () => { } // GET /v1/projects/{ref} — read by the linked-project cache. if (recorded.method === "GET" && url.includes(`/v1/projects/${VALID_REF}`)) { - return Effect.succeed(jsonResponse(request, 200, PROJECT)); + return Effect.succeed(jsonResponse(request, 200, PROJECT_DETAIL)); } return Effect.succeed(jsonResponse(request, 404, {})); }; @@ -309,6 +328,23 @@ describe("bootstrap linked-project cache location", () => { // With no `supabase/seed.sql` file, the seed glob matches nothing, so this line only // prints when `includeSeed` is true. expect(out.stderrText).toContain("Seed files are up to date."); + + // The tenant service probes share the recording client; only the Management API + // requests are mapped. + expect( + api.requests + .filter(({ url }) => new URL(url).origin === "https://api.supabase.com") + .map(({ method, url }) => `${method} ${url.slice("https://api.supabase.com".length)}`), + ).toEqual([ + "GET /v1/organizations", + "POST /v1/projects", + `GET /v1/projects/${VALID_REF}/api-keys`, + `GET /v1/projects/${VALID_REF}/config/storage`, + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + `GET /v1/projects/${VALID_REF}/health`, + `GET /v1/projects/${VALID_REF}`, + ]); + assertPermissionDrift({ command: "bootstrap", requests: api.requests, exact: true }); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); }); diff --git a/apps/cli/src/commands/branches/branches.prompt.ts b/apps/cli/src/commands/branches/branches.prompt.ts index 9f6f6951e0..48e842d756 100644 --- a/apps/cli/src/commands/branches/branches.prompt.ts +++ b/apps/cli/src/commands/branches/branches.prompt.ts @@ -3,6 +3,7 @@ import { styleText } from "node:util"; import { Effect, Option } from "effect"; import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts"; +import { block } from "../../command-internal/command-permissions/model.ts"; import { mapHttpError } from "../../command-internal/http-errors.ts"; import { Output } from "../../shared/output/output.service.ts"; import { detectGitBranch } from "../../shared/git/git-branch.ts"; @@ -14,6 +15,15 @@ import { BranchesListUnexpectedStatusError, } from "./branches.errors.ts"; +/** `promptBranchId`'s branch listing, shown when no branch is given on a TTY. */ +export const branchPrompt = block("branch-prompt", [ + { + operationId: "v1-list-all-branches", + kind: "required", + context: "the branch argument is omitted or empty and stdin is a TTY", + }, +]); + const mapListError = mapHttpError({ networkError: BranchesListNetworkError, statusError: BranchesListUnexpectedStatusError, diff --git a/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md index 2b28867a75..e8045e6dd4 100644 --- a/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/create/SIDE_EFFECTS.md @@ -13,18 +13,20 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after the command runs (in `Effect.ensuring`), unless the file already exists, `.temp/project-ref` names a different project, no access token resolves, or the project lookup is not 200 | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------ | -| `POST` | `/v1/projects/{ref}/branches` | Bearer token | `{branch_name, is_default: false, git_branch?, region?, desired_instance_size?, persistent?, with_data?, notify_url?}` | full `BranchResponse` | -| `GET` | `/v1/projects/{ref}` (on 4xx gated) | Bearer token | none | `{organization_slug}` | -| `GET` | `/v1/organizations/{slug}/entitlements` (gated) | Bearer token | none | `[{feature: {key}, hasAccess, ...}]` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------------------- | ------------ | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST` | `/v1/projects/{ref}/branches` | Bearer token | `{branch_name, is_default: false, git_branch?, region?, desired_instance_size?, persistent?, with_data?, notify_url?}` | full `BranchResponse` | +| `GET` | `/v1/projects/{ref}` (on 4xx gated) | Bearer token | none | `{organization_slug}` | +| `GET` | `/v1/organizations/{slug}/entitlements` (gated) | Bearer token | none | `[{feature: {key}, hasAccess, ...}]` | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region, …}]` — no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | ## Environment Variables diff --git a/apps/cli/src/commands/branches/create/create.command.ts b/apps/cli/src/commands/branches/create/create.command.ts index 20cd15af79..4af369ac66 100644 --- a/apps/cli/src/commands/branches/create/create.command.ts +++ b/apps/cli/src/commands/branches/create/create.command.ts @@ -2,6 +2,11 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { Layer } from "effect"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; @@ -92,6 +97,17 @@ export type BranchesCreateFlags = CliCommand.Command.Config.Infer export const branchesCreateCommand = Command.make("create", config).pipe( Command.withDescription("Create a preview branch for the linked project."), Command.withShortDescription("Create a preview branch"), + withPermissions( + compose( + { + operations: [{ operationId: "v1-create-a-branch", kind: "required" }], + noApiEffectFlags: ["region", "size", "persistent", "with-data", "notify-url", "git-branch"], + }, + projectRefPicker, + upgradeSuggestion, + linkedProjectCache, + ), + ), Command.withHandler((flags) => branchesCreate(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"], config }), diff --git a/apps/cli/src/commands/branches/create/create.integration.test.ts b/apps/cli/src/commands/branches/create/create.integration.test.ts index c7f7414192..3750a7e6b8 100644 --- a/apps/cli/src/commands/branches/create/create.integration.test.ts +++ b/apps/cli/src/commands/branches/create/create.integration.test.ts @@ -1,6 +1,6 @@ import type { V1CreateABranchOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option, Schema } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema } from "effect"; import { CliError, CliOutput, Command } from "effect/cli"; import { @@ -22,6 +22,8 @@ import { useTempWorkdir, withConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { branchesCreateCommand, type BranchesCreateFlags } from "./create.command.ts"; import { branchesCreate } from "./create.handler.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; @@ -29,6 +31,8 @@ import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts type CreatedBranch = typeof V1CreateABranchOutput.Type; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const CREATED: CreatedBranch = { id: "11111111-2222-4333-8444-555555555555", name: "feat-x", @@ -183,9 +187,67 @@ describe("branches create integration", () => { branch_name: "feat-x", is_default: false, }); + assertPermissionDrift({ command: "branches create", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); + it.effect( + "lists projects for the picker, creates the branch, then fills the linked-project cache", + () => { + const out = mockOutput({ format: "text", promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.method === "POST") { + return Effect.succeed(jsonResponse(request, 201, CREATED)); + } + return Effect.succeed( + jsonResponse( + request, + 200, + request.url === "https://api.supabase.com/v1/projects" + ? [projectResponse()] + : projectResponse(), + ), + ); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }), + Layer.succeed(YesFlag, false), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* branchesCreate({ ...baseFlags, name: Option.some("feat-x") }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `POST https://api.supabase.com/v1/projects/${VALID_REF}/branches`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain("Created preview branch:"); + expect(out.stdoutText).toContain("feat-x"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "branches create", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("includes optional flags in the request body only when set", () => { const { layer, api } = setup(); return Effect.gen(function* () { @@ -205,6 +267,17 @@ describe("branches create integration", () => { with_data: true, notify_url: "https://hook.example.com", }); + assertPermissionDrift({ + command: "branches create", + activeFlags: { + region: true, + persistent: true, + "with-data": true, + "notify-url": true, + }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -366,9 +439,15 @@ describe("branches create integration", () => { }); it.effect("fires cli_upgrade_suggested with feature_key=branching_limit on 402 gated", () => { - const { layer, analytics } = setup({ status: 402, gated: true }); + const { layer, analytics, api } = setup({ status: 402, gated: true }); return Effect.gen(function* () { yield* Effect.exit(branchesCreate({ ...baseFlags, name: Option.some("feat-x") })); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `POST https://api.supabase.com/v1/projects/${VALID_REF}/branches`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + `GET https://api.supabase.com/v1/organizations/${ORG_SLUG}/entitlements`, + ]); + assertPermissionDrift({ command: "branches create", requests: api.requests }); expect(analytics.captured).toEqual([ { event: "cli_upgrade_suggested", @@ -397,6 +476,7 @@ describe("branches create integration", () => { expect(Exit.isFailure(exit)).toBe(true); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain("/branches"); + assertPermissionDrift({ command: "branches create", requests: api.requests, exact: true }); expect(out.stderrText).toContain("/org/env-org/billing"); expect(analytics.captured).toEqual([ { diff --git a/apps/cli/src/commands/branches/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/delete/SIDE_EFFECTS.md index aebfcdc37f..0c8666b011 100644 --- a/apps/cli/src/commands/branches/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/delete/SIDE_EFFECTS.md @@ -6,18 +6,21 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after the command runs (in `Effect.ensuring`), unless the file already exists, `.temp/project-ref` names a different project, no access token resolves, or the project lookup is not 200 | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | When | Response | -| -------- | ------------------------------------ | ------------ | -------------------------------------------------------------- | ------------------- | -| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | `{project_ref}` | -| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | `{ref}` | -| `DELETE` | `/v1/branches/{branch_id_or_ref}` | Bearer token | always — `force` query param omitted | `{ message: "ok" }` | +| Method | Path | Auth | When | Response | +| -------- | ------------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | `{project_ref}` | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | `{ref}` | +| `DELETE` | `/v1/branches/{branch_id_or_ref}` | Bearer token | always — `force` query param omitted | `{ message: "ok" }` | +| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | `[{name, project_ref, …}]` | +| `GET` | `/v1/projects` | Bearer token | no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | `[{id, name, organization_slug, region, …}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | `{ref, name, organization_id, organization_slug}` | ## Environment Variables diff --git a/apps/cli/src/commands/branches/delete/delete.command.ts b/apps/cli/src/commands/branches/delete/delete.command.ts index d2a8d7a42c..5e1e075de0 100644 --- a/apps/cli/src/commands/branches/delete/delete.command.ts +++ b/apps/cli/src/commands/branches/delete/delete.command.ts @@ -1,6 +1,12 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { branchPrompt } from "../branches.prompt.ts"; +import { branchRef } from "../../../command-internal/branch-ref.resolver.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -21,6 +27,15 @@ export type BranchesDeleteFlags = CliCommand.Command.Config.Infer export const branchesDeleteCommand = Command.make("delete", config).pipe( Command.withDescription("Delete a preview branch by its name or ID."), Command.withShortDescription("Delete a preview branch"), + withPermissions( + compose( + { operations: [{ operationId: "v1-delete-a-branch", kind: "required" }] }, + projectRefPicker, + branchPrompt, + branchRef, + linkedProjectCache, + ), + ), Command.withHandler((flags) => branchesDelete(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), diff --git a/apps/cli/src/commands/branches/delete/delete.integration.test.ts b/apps/cli/src/commands/branches/delete/delete.integration.test.ts index 26b12ec97f..43aa3c305c 100644 --- a/apps/cli/src/commands/branches/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/branches/delete/delete.integration.test.ts @@ -1,8 +1,10 @@ +import { type V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Path } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Path, Schema } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { + VALID_REF, buildTestRuntime, jsonResponse, mockCommandSettings, @@ -11,6 +13,12 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { + assertBlockDrift, + assertPermissionDrift, +} from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; +import { branchPrompt } from "../branches.prompt.ts"; import type { BranchesDeleteFlags } from "./delete.command.ts"; import { branchesDelete } from "./delete.handler.ts"; @@ -30,8 +38,47 @@ const BRANCH_CONFIG = { db_port: 5432, }; +const BRANCH_REF = "cccccccccccccccccccc"; +const OTHER_BRANCH_REF = "dddddddddddddddddddd"; +const API = "https://api.supabase.com"; + +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + +const branchListing = (projectRef: string, name: string) => ({ + id: "33333333-3333-4333-8333-333333333333", + name, + project_ref: projectRef, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-01T00:00:00Z", + with_data: false, +}); + const tempRoot = useTempWorkdir("supabase-branches-delete-int-"); +const sequence = (requests: ReadonlyArray<{ method: string; url: string }>) => + requests.map(({ method, url }) => `${method} ${url}`); + interface SetupOpts { readonly deleteStatus?: number; } @@ -90,6 +137,11 @@ describe("branches delete integration", () => { expect(api.requests.find((r) => r.method === "DELETE")?.url).toContain( `/v1/branches/${BRANCH_UUID}`, ); + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/branches/${BRANCH_UUID}`, + `DELETE ${API}/v1/branches/${BRANCH_UUID}`, + ]); + assertPermissionDrift({ command: "branches delete", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -194,6 +246,76 @@ describe("branches delete integration", () => { expect(lookup?.url).toContain(`/v1/projects/${PARENT_REF}/branches/my-feature`); const del = api.requests.find((r) => r.method === "DELETE"); expect(del?.url).toContain(`/v1/branches/${RESOLVED_BRANCH_UUID}`); + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/projects/${PARENT_REF}/branches/my-feature`, + `DELETE ${API}/v1/branches/${RESOLVED_BRANCH_UUID}`, + ]); + assertPermissionDrift({ command: "branches delete", requests: api.requests }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "deletes the branch chosen from the pickers and fills the real linked-project cache", + () => { + const out = mockOutput({ format: "text", promptSelectResponses: [VALID_REF, BRANCH_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "GET" && request.url === `${API}/v1/projects`) { + return jsonResponse(request, 200, [PROJECT]); + } + if (request.method === "GET" && request.url === `${API}/v1/projects/${VALID_REF}`) { + return jsonResponse(request, 200, PROJECT); + } + if ( + request.method === "GET" && + request.url === `${API}/v1/projects/${VALID_REF}/branches` + ) { + return jsonResponse(request, 200, [ + branchListing(OTHER_BRANCH_REF, "other"), + branchListing(BRANCH_REF, "feat-x"), + ]); + } + if (request.method === "DELETE" && request.url === `${API}/v1/branches/${BRANCH_REF}`) { + return jsonResponse(request, 200, { message: "ok" }); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* branchesDelete(baseFlags); + + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/projects`, + `GET ${API}/v1/projects/${VALID_REF}/branches`, + `DELETE ${API}/v1/branches/${BRANCH_REF}`, + `GET ${API}/v1/projects/${VALID_REF}`, + ]); + expect(out.stderrText).toContain(`Deleted preview branch: ${BRANCH_REF}`); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "branches delete", requests: api.requests, exact: true }); + assertBlockDrift(branchPrompt, { requests: api.requests.slice(1, 2) }); }).pipe(Effect.provide(layer)); }, ); diff --git a/apps/cli/src/commands/branches/disable/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/disable/SIDE_EFFECTS.md index 32a800779f..b33126d19a 100644 --- a/apps/cli/src/commands/branches/disable/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/disable/SIDE_EFFECTS.md @@ -8,16 +8,18 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after the command runs (in `Effect.ensuring`), unless the file already exists, `.temp/project-ref` names a different project, no access token resolves, or the project lookup is not 200 | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | Request body | Response | -| -------- | ----------------------------- | ------------ | ------------ | ----------------- | -| `DELETE` | `/v1/projects/{ref}/branches` | Bearer token | none | none (expect 200) | +| Method | Path | Auth | Request body | Response | +| -------- | ----------------------------- | ------------ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DELETE` | `/v1/projects/{ref}/branches` | Bearer token | none | none (expect 200) | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region, …}]` — no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | ## Environment Variables diff --git a/apps/cli/src/commands/branches/disable/disable.command.ts b/apps/cli/src/commands/branches/disable/disable.command.ts index ed13186d2f..421b834820 100644 --- a/apps/cli/src/commands/branches/disable/disable.command.ts +++ b/apps/cli/src/commands/branches/disable/disable.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -18,6 +22,13 @@ export type BranchesDisableFlags = CliCommand.Command.Config.Infer branchesDisable(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), diff --git a/apps/cli/src/commands/branches/disable/disable.integration.test.ts b/apps/cli/src/commands/branches/disable/disable.integration.test.ts index 1ca9da0e76..4f8231ea12 100644 --- a/apps/cli/src/commands/branches/disable/disable.integration.test.ts +++ b/apps/cli/src/commands/branches/disable/disable.integration.test.ts @@ -1,19 +1,42 @@ +import type { V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import type { BranchesDisableFlags } from "./disable.command.ts"; import { branchesDisable } from "./disable.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + const tempRoot = useTempWorkdir("supabase-branches-disable-int-"); interface SetupOpts { @@ -61,9 +84,69 @@ describe("branches disable integration", () => { expect(api.requests.find((r) => r.method === "DELETE")?.url).toContain( `/v1/projects/${VALID_REF}/branches`, ); + assertPermissionDrift({ + command: "branches disable", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); + it.effect( + "lists projects for the picker, disables branching, then fills the linked-project cache", + () => { + const out = mockOutput({ format: "text", promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.method === "DELETE") { + return Effect.succeed(jsonResponse(request, 200, null)); + } + return Effect.succeed( + jsonResponse( + request, + 200, + request.url === "https://api.supabase.com/v1/projects" ? [PROJECT] : PROJECT, + ), + ); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* branchesDisable(baseFlags); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/branches`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain(`Disabled preview branching for project: ${VALID_REF}`); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "branches disable", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("fails with BranchesDisableUnexpectedStatusError on non-200", () => { const { layer } = setup({ status: 500 }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/branches/get/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/get/SIDE_EFFECTS.md index d566a13b85..abf73a074a 100644 --- a/apps/cli/src/commands/branches/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/get/SIDE_EFFECTS.md @@ -6,19 +6,22 @@ Same auth fallback chain (env / keyring / `~/.supabase/access-token`) as every M ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after the command runs (in `Effect.ensuring`), unless the file already exists, `.temp/project-ref` names a different project, no access token resolves, or the project lookup is not 200 | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | When | Response (used fields) | -| ------ | ------------------------------------------- | ------------ | ----------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | input is neither a UUID nor a `^[a-z]{20}$` ref pattern (named-branch lookup) | `{project_ref, ...}` | -| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | always — branch detail / config | `{ref, db_host, db_port, db_user?, db_pass?, jwt_secret?, postgres_version, status, …}` | -| `GET` | `/v1/projects/{ref}/api-keys` | Bearer token | only when `--output` is not `pretty` | `[{name, api_key?}]` | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | only when `--output` is not `pretty` | `[SupavisorConfigResponse]` — handler filters for `database_type === "PRIMARY"` | +| Method | Path | Auth | When | Response (used fields) | +| ------ | ------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | input is neither a UUID nor a `^[a-z]{20}$` ref pattern (named-branch lookup) | `{project_ref, ...}` | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | always — branch detail / config | `{ref, db_host, db_port, db_user?, db_pass?, jwt_secret?, postgres_version, status, …}` | +| `GET` | `/v1/projects/{ref}/api-keys` | Bearer token | with `-o` other than `pretty`, or with no `-o` when the output format is json or stream-json (`--output-format`, `--agent yes`, or a detected coding agent) | `[{name, api_key?}]` | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | with `-o` other than `pretty`, or with no `-o` when the output format is json or stream-json (`--output-format`, `--agent yes`, or a detected coding agent) | `[SupavisorConfigResponse]` — handler filters for `database_type === "PRIMARY"` | +| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | `[{name, project_ref, …}]` | +| `GET` | `/v1/projects` | Bearer token | no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | `[{id, name, organization_slug, region, …}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | `{ref, name, organization_id, organization_slug}` | ## Environment Variables diff --git a/apps/cli/src/commands/branches/get/get.command.ts b/apps/cli/src/commands/branches/get/get.command.ts index 5d6b0a34e7..ea78c0721f 100644 --- a/apps/cli/src/commands/branches/get/get.command.ts +++ b/apps/cli/src/commands/branches/get/get.command.ts @@ -1,11 +1,48 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + type EntryCondition, +} from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { branchPrompt } from "../branches.prompt.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { branchesGet } from "./get.handler.ts"; +// `get.handler.ts` skips the api-keys and pooler calls for `-o pretty`, and with no `-o` when the output format is text. +const MACHINE_OUTPUT: ReadonlyArray<{ + readonly when: ReadonlyArray; + readonly context?: string; +}> = [ + { when: [{ flag: "output", oneOf: ["env", "json", "toml", "yaml", "table", "csv"] }] }, + { + when: [ + { flag: "output", present: false }, + { flag: "output-format", oneOf: ["json", "stream-json"] }, + ], + }, + { + when: [ + { flag: "output", present: false }, + { flag: "output-format", present: false }, + { flag: "agent", oneOf: ["yes"] }, + ], + }, + { + when: [ + { flag: "output", present: false }, + { flag: "output-format", present: false }, + { flag: "agent", oneOf: ["auto"] }, + ], + context: "a coding agent is detected in the environment", + }, +]; + const config = { name: Argument.String("name").pipe( Argument.withDescription("Branch name or ID."), @@ -24,6 +61,32 @@ export const branchesGetCommand = Command.make("get", config).pipe( "Retrieve details of the specified preview branch.\n\nNote: For the main branch, password-dependent fields (POSTGRES_URL, POSTGRES_URL_NON_POOLING) are not populated because production database credentials are not retrievable via API.", ), Command.withShortDescription("Retrieve details of a preview branch"), + withPermissions( + compose( + { + operations: [ + { + operationId: "v1-get-a-branch", + kind: "required", + context: + "the branch given (argument or typed answer) is a name, not a UUID or a 20-letter project ref", + }, + { operationId: "v1-get-a-branch-config", kind: "required" }, + ...MACHINE_OUTPUT.flatMap(({ when, context }) => + ["v1-get-project-api-keys", "v1-get-pooler-config"].map((operationId) => ({ + operationId, + kind: "required" as const, + when, + ...(context === undefined ? {} : { context }), + })), + ), + ], + }, + projectRefPicker, + branchPrompt, + linkedProjectCache, + ), + ), Command.withHandler((flags) => branchesGet(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), diff --git a/apps/cli/src/commands/branches/get/get.integration.test.ts b/apps/cli/src/commands/branches/get/get.integration.test.ts index 3222317edd..930fa9a1da 100644 --- a/apps/cli/src/commands/branches/get/get.integration.test.ts +++ b/apps/cli/src/commands/branches/get/get.integration.test.ts @@ -3,11 +3,13 @@ import type { V1GetABranchOutput, V1GetPoolerConfigOutput, V1GetProjectApiKeysOutput, + V1ListAllBranchesOutput, + V1ListAllProjectsOutput, } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, @@ -16,6 +18,8 @@ import { mockCommandPlatformApi, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import type { BranchesGetFlags } from "./get.command.ts"; import { branchesGet } from "./get.handler.ts"; @@ -24,6 +28,8 @@ type FindResponse = typeof V1GetABranchOutput.Type; type Pooler = typeof V1GetPoolerConfigOutput.Type; type ApiKeys = typeof V1GetProjectApiKeysOutput.Type; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + // A UUID keeps `branch_id_or_ref`'s oneOf union unambiguous; a 20-lowercase ref could match // either variant. const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; @@ -89,11 +95,46 @@ const KEYS: ApiKeys = [ { name: "service_role", api_key: "sr-key" }, ]; +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + +const LISTED_BRANCH: (typeof V1ListAllBranchesOutput.Type)[number] = { + id: BRANCH_UUID, + name: "feat-x", + project_ref: BRANCH_REF, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2024-01-01T00:00:00Z", + updated_at: "2024-01-01T00:00:00Z", + with_data: false, +}; + +const API = "https://api.supabase.com"; +const DETAIL_REQUEST = `GET ${API}/v1/branches/${BRANCH_UUID}`; +const API_KEYS_REQUEST = `GET ${API}/v1/projects/${BRANCH_REF}/api-keys`; +const POOLER_REQUEST = `GET ${API}/v1/projects/${BRANCH_REF}/config/database/pooler`; + const tempRoot = useTempWorkdir("supabase-branches-get-int-"); interface SetupOpts { readonly format?: "text" | "json"; - readonly outputFlag?: "env" | "pretty" | "json" | "toml" | "yaml"; + readonly outputFlag?: "env" | "pretty" | "json" | "toml" | "yaml" | "table"; readonly findStatus?: number; readonly detailStatus?: number; readonly detailBody?: BranchDetail; @@ -142,6 +183,10 @@ function buildApi(opts: SetupOpts) { }); } +function requested(api: { readonly requests: ReadonlyArray<{ method: string; url: string }> }) { + return api.requests.map(({ method, url }) => `${method} ${url}`); +} + function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); const api = buildApi(opts); @@ -161,6 +206,52 @@ const baseFlags: BranchesGetFlags = { }; describe("branches get integration", () => { + it.effect("picks the project and branch on a TTY and fills the linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF, BRANCH_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.url === `${API}/v1/projects` + ? jsonResponse(request, 200, [PROJECT]) + : request.url === `${API}/v1/projects/${VALID_REF}/branches` + ? jsonResponse(request, 200, [LISTED_BRANCH]) + : request.url === `${API}/v1/branches/${BRANCH_REF}` + ? jsonResponse(request, 200, DETAIL) + : request.url === `${API}/v1/projects/${VALID_REF}` + ? jsonResponse(request, 200, PROJECT) + : jsonResponse(request, 404, { message: "unrouted" }), + ), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* branchesGet(baseFlags); + expect(requested(api)).toEqual([ + `GET ${API}/v1/projects`, + `GET ${API}/v1/projects/${VALID_REF}/branches`, + `GET ${API}/v1/branches/${BRANCH_REF}`, + `GET ${API}/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain("db.cccccccccccccccccccc.supabase.co"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "branches get", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + it.effect("fetches branch detail directly when input is a UUID (no lookup)", () => { const { layer, api } = setup(); return Effect.gen(function* () { @@ -182,23 +273,48 @@ describe("branches get integration", () => { api.requests.some((r) => r.url.includes(`/v1/projects/${VALID_REF}/branches/feat-x`)), ).toBe(true); expect(api.requests.some((r) => r.url.includes(`/v1/branches/${BRANCH_UUID}`))).toBe(true); + assertPermissionDrift({ command: "branches get", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); it.effect("renders pretty 7-col table with masked ****** for missing credentials", () => { - const { layer, out } = setup({ detailBody: DETAIL_MASKED }); + const { layer, out, api } = setup({ detailBody: DETAIL_MASKED }); return Effect.gen(function* () { yield* branchesGet({ ...baseFlags, name: Option.some(BRANCH_UUID) }); expect(out.stdoutText).toContain("HOST"); expect(out.stdoutText).toContain("******"); expect(out.stdoutText).toContain("ACTIVE_HEALTHY"); + expect(requested(api)).toEqual([DETAIL_REQUEST]); + assertPermissionDrift({ command: "branches get", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("skips api keys and pooler config for --output pretty", () => { + const { layer, out, api } = setup({ outputFlag: "pretty" }); + return Effect.gen(function* () { + yield* branchesGet({ ...baseFlags, name: Option.some(BRANCH_UUID) }); + expect(out.stdoutText).toContain("HOST"); + expect(requested(api)).toEqual([DETAIL_REQUEST]); + assertPermissionDrift({ + command: "branches get", + activeFlags: { output: "pretty" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); it.effect("emits standard-env map for --output-format=json", () => { - const { layer, out } = setup({ format: "json" }); + const { layer, out, api } = setup({ format: "json" }); return Effect.gen(function* () { yield* branchesGet({ ...baseFlags, name: Option.some(BRANCH_UUID) }); + expect(requested(api)).toEqual([DETAIL_REQUEST, API_KEYS_REQUEST, POOLER_REQUEST]); + assertPermissionDrift({ + command: "branches get", + activeFlags: { "output-format": "json" }, + requests: api.requests, + exact: true, + }); const success = out.messages.find((m) => m.type === "success"); expect(success).toBeDefined(); expect(success?.data).toHaveProperty("POSTGRES_URL"); @@ -237,9 +353,15 @@ describe("branches get integration", () => { it.effect( "keeps env-map keys verbatim for --output toml (map payload, exempt from CLI-1975)", () => { - const { layer, out } = setup({ outputFlag: "toml" }); + const { layer, out, api } = setup({ outputFlag: "toml" }); return Effect.gen(function* () { yield* branchesGet({ ...baseFlags, name: Option.some(BRANCH_UUID) }); + assertPermissionDrift({ + command: "branches get", + activeFlags: { output: "toml" }, + requests: api.requests, + exact: true, + }); // Map payloads are exempt from the struct-field PascalCase key remap; keys stay verbatim. expect(out.stdoutText).toContain('SUPABASE_URL = "'); expect(out.stdoutText).toContain('POSTGRES_URL = "'); @@ -248,14 +370,62 @@ describe("branches get integration", () => { ); it.effect("emits standard-env map for --output env (env-format encoder)", () => { - const { layer, out } = setup({ outputFlag: "env" }); + const { layer, out, api } = setup({ outputFlag: "env" }); return Effect.gen(function* () { yield* branchesGet({ ...baseFlags, name: Option.some(BRANCH_UUID) }); expect(out.stdoutText).toContain("SUPABASE_URL="); expect(out.stdoutText).toContain("SUPABASE_ANON_KEY="); + expect(requested(api)).toEqual([DETAIL_REQUEST, API_KEYS_REQUEST, POOLER_REQUEST]); + assertPermissionDrift({ + command: "branches get", + activeFlags: { output: "env" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); + it.effect("fetches api keys and pooler config and prints JSON for -o table", () => { + const { layer, out, api } = setup({ outputFlag: "table" }); + return Effect.gen(function* () { + yield* branchesGet({ ...baseFlags, name: Option.some(BRANCH_UUID) }); + expect(requested(api)).toEqual([DETAIL_REQUEST, API_KEYS_REQUEST, POOLER_REQUEST]); + expect(yield* decodeJson(out.stdoutText)).toMatchObject({ + SUPABASE_ANON_KEY: "anon-key", + SUPABASE_SERVICE_ROLE_KEY: "sr-key", + }); + assertPermissionDrift({ + command: "branches get", + activeFlags: { output: "table" }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + for (const agent of ["auto", "yes"] as const) { + it.effect( + `fetches api keys and pooler config when --agent ${agent} yields json output (format simulated)`, + () => { + const { layer, out, api } = setup({ format: "json" }); + return Effect.gen(function* () { + yield* branchesGet({ ...baseFlags, name: Option.some(BRANCH_UUID) }); + expect(requested(api)).toEqual([DETAIL_REQUEST, API_KEYS_REQUEST, POOLER_REQUEST]); + expect(out.messages.find((m) => m.type === "success")?.data).toHaveProperty( + "SUPABASE_ANON_KEY", + "anon-key", + ); + assertPermissionDrift({ + command: "branches get", + activeFlags: { agent }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }, + ); + } + it.effect("writes WARNING to stderr when pooler parse fails in text mode", () => { const broken: Pooler = [ { @@ -315,7 +485,7 @@ describe("branches get integration", () => { }); it.effect("fails with BranchesApiKeysUnexpectedStatusError on api-keys 403", () => { - const { layer } = setup({ outputFlag: "json", apiKeysStatus: 403 }); + const { layer, api } = setup({ outputFlag: "json", apiKeysStatus: 403 }); return Effect.gen(function* () { const exit = yield* Effect.exit( branchesGet({ ...baseFlags, name: Option.some(BRANCH_UUID) }), @@ -324,7 +494,14 @@ describe("branches get integration", () => { if (Exit.isFailure(exit)) { const causeText = Cause.pretty(exit.cause); expect(causeText).toContain("BranchesApiKeysUnexpectedStatusError"); + expect(causeText).toContain("unexpected get api keys status 403"); } + expect(requested(api)).toEqual([DETAIL_REQUEST, API_KEYS_REQUEST]); + assertPermissionDrift({ + command: "branches get", + activeFlags: { output: "json" }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md index 7c3df249a0..98621193ed 100644 --- a/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/list/SIDE_EFFECTS.md @@ -29,16 +29,18 @@ ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after the command runs (in `Effect.ensuring`), unless the file already exists, `.temp/project-ref` names a different project, no access token resolves, or the project lookup is not 200 | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/projects/{ref}/branches` | Bearer token | none | `[{id, name, project_ref, parent_project_ref, is_default, git_branch?, persistent, status, created_at, updated_at, with_data, ...}]` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------- | ------------ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/branches` | Bearer token | none | `[{id, name, project_ref, parent_project_ref, is_default, git_branch?, persistent, status, created_at, updated_at, with_data, ...}]` | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region, …}]` — no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, after the command) | ## Environment Variables diff --git a/apps/cli/src/commands/branches/list/list.command.ts b/apps/cli/src/commands/branches/list/list.command.ts index 2b15000ace..e87f189c78 100644 --- a/apps/cli/src/commands/branches/list/list.command.ts +++ b/apps/cli/src/commands/branches/list/list.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -18,6 +22,13 @@ export type BranchesListFlags = CliCommand.Command.Config.Infer; export const branchesListCommand = Command.make("list", config).pipe( Command.withDescription("List all preview branches of the linked project."), Command.withShortDescription("List all preview branches"), + withPermissions( + compose( + { operations: [{ operationId: "v1-list-all-branches", kind: "required" }] }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => branchesList(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), diff --git a/apps/cli/src/commands/branches/list/list.integration.test.ts b/apps/cli/src/commands/branches/list/list.integration.test.ts index 0db1d70931..31fad5c929 100644 --- a/apps/cli/src/commands/branches/list/list.integration.test.ts +++ b/apps/cli/src/commands/branches/list/list.integration.test.ts @@ -1,21 +1,43 @@ -import type { V1ListAllBranchesOutput } from "@supabase/api/effect"; +import type { V1ListAllBranchesOutput, V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Path } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Path, Schema } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { branchesList } from "./list.handler.ts"; type Branches = typeof V1ListAllBranchesOutput.Type; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + const SAMPLE_BRANCH: Branches[number] = { id: "11111111-2222-4333-8444-555555555555", name: "feat-1", @@ -304,9 +326,58 @@ describe("branches list integration", () => { yield* branchesList({ projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/branches`); + assertPermissionDrift({ command: "branches list", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); + it.effect( + "lists projects for the picker, then the branches, then fills the linked-project cache", + () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [PROJECT])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, PROJECT)); + } + return Effect.succeed(jsonResponse(request, 200, [SAMPLE_BRANCH])); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* branchesList({ projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/branches`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain("feat-1"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "branches list", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); + describe("parent-scoped resolution after linking a branch (CLI-2167 follow-up)", () => { it.effect( "resolves the linked PARENT (not the branch's own ref) when linked to a branch, and renders the table", @@ -323,6 +394,7 @@ describe("branches list integration", () => { expect(api.requests[0]?.url).toContain(`/v1/projects/${PARENT_REF}/branches`); expect(out.stdoutText).toContain("STATUS"); expect(out.stdoutText).toContain("feat-1"); + assertPermissionDrift({ command: "branches list", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }, ); @@ -339,6 +411,42 @@ describe("branches list integration", () => { yield* writeLinkedProjectCacheFile(workdir, PARENT_REF); yield* branchesList({ projectRef: Option.some(EXPLICIT_REF) }); expect(api.requests[0]?.url).toContain(`/v1/projects/${EXPLICIT_REF}/branches`); + assertPermissionDrift({ + command: "branches list", + activeFlags: { "project-ref": true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "an empty --project-ref resolves the linked parent from the project-ref file without the picker", + () => { + const api = mockCommandPlatformApi({ + response: { status: 200, body: [SAMPLE_BRANCH] }, + }); + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const layer = buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current, projectId: Option.none() }), + tty: mockTty({ stdinIsTty: true }), + }); + return Effect.gen(function* () { + yield* writeProjectRefFile(tempRoot.current, FILE_ONLY_REF); + yield* branchesList({ projectRef: Option.some("") }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${FILE_ONLY_REF}/branches`, + ]); + expect(out.promptSelectCalls).toHaveLength(0); + assertPermissionDrift({ + command: "branches list", + activeFlags: { "project-ref": "" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }, ); diff --git a/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md index aafdd4bce8..aa7565b030 100644 --- a/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/pause/SIDE_EFFECTS.md @@ -6,18 +6,21 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after the command runs (in `Effect.ensuring`), unless the file already exists, `.temp/project-ref` names a different project, no access token resolves, or the project lookup is not 200 | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | When | Response | -| ------ | ------------------------------------ | ------------ | -------------------------------------------------------------- | ----------------- | -| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | `{project_ref}` | -| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | `{ref}` | -| `POST` | `/v1/projects/{branch_ref}/pause` | Bearer token | always — final pause action | none (expect 200) | +| Method | Path | Auth | When | Response | +| ------ | ------------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | `{project_ref}` | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | `{ref}` | +| `POST` | `/v1/projects/{branch_ref}/pause` | Bearer token | always — final pause action | none (expect 200) | +| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | `[{name, project_ref, …}]` | +| `GET` | `/v1/projects` | Bearer token | no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | `[{id, name, organization_slug, region, …}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | `{ref, name, organization_id, organization_slug}` | ## Environment Variables diff --git a/apps/cli/src/commands/branches/pause/pause.command.ts b/apps/cli/src/commands/branches/pause/pause.command.ts index 5361025bbe..dc9645edbd 100644 --- a/apps/cli/src/commands/branches/pause/pause.command.ts +++ b/apps/cli/src/commands/branches/pause/pause.command.ts @@ -1,6 +1,12 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { branchPrompt } from "../branches.prompt.ts"; +import { branchRef } from "../../../command-internal/branch-ref.resolver.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -22,6 +28,15 @@ export type BranchesPauseFlags = CliCommand.Command.Config.Infer; export const branchesPauseCommand = Command.make("pause", config).pipe( Command.withDescription("Pause a preview branch."), Command.withShortDescription("Pause a preview branch"), + withPermissions( + compose( + { operations: [{ operationId: "v1-pause-a-project", kind: "required" }] }, + projectRefPicker, + branchPrompt, + branchRef, + linkedProjectCache, + ), + ), Command.withHandler((flags) => branchesPause(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), diff --git a/apps/cli/src/commands/branches/pause/pause.integration.test.ts b/apps/cli/src/commands/branches/pause/pause.integration.test.ts index c3ac7a5473..5b8d2c4d20 100644 --- a/apps/cli/src/commands/branches/pause/pause.integration.test.ts +++ b/apps/cli/src/commands/branches/pause/pause.integration.test.ts @@ -1,7 +1,8 @@ +import { type V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, @@ -12,6 +13,8 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import type { BranchesPauseFlags } from "./pause.command.ts"; import { branchesPause } from "./pause.handler.ts"; @@ -42,8 +45,60 @@ const BRANCH_NAMED_LOOKUP = { with_data: false, }; +const OTHER_BRANCH_REF = "dddddddddddddddddddd"; +const API = "https://api.supabase.com"; + +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + const tempRoot = useTempWorkdir("supabase-branches-pause-int-"); +const sequence = (requests: ReadonlyArray<{ method: string; url: string }>) => + requests.map(({ method, url }) => `${method} ${url}`); + +function pickerApi() { + return mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "GET" && request.url === `${API}/v1/projects`) { + return jsonResponse(request, 200, [PROJECT]); + } + if (request.method === "GET" && request.url === `${API}/v1/projects/${VALID_REF}`) { + return jsonResponse(request, 200, PROJECT); + } + if ( + request.method === "GET" && + request.url === `${API}/v1/projects/${VALID_REF}/branches` + ) { + return jsonResponse(request, 200, [ + { ...BRANCH_NAMED_LOOKUP, name: "other", project_ref: OTHER_BRANCH_REF }, + BRANCH_NAMED_LOOKUP, + ]); + } + if (request.method === "POST" && request.url === `${API}/v1/projects/${BRANCH_REF}/pause`) { + return jsonResponse(request, 200, null); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); +} + interface SetupOpts { readonly pauseStatus?: number; } @@ -107,6 +162,7 @@ describe("branches pause integration", () => { const post = api.requests.find((r) => r.method === "POST"); expect(post?.url).toContain(`/v1/projects/${BRANCH_REF}/pause`); expect(api.requests.find((r) => r.method === "GET")).toBeUndefined(); + assertPermissionDrift({ command: "branches pause", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -120,6 +176,11 @@ describe("branches pause integration", () => { expect(api.requests.find((r) => r.method === "POST")?.url).toContain( `/v1/projects/${BRANCH_REF}/pause`, ); + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/branches/${BRANCH_UUID}`, + `POST ${API}/v1/projects/${BRANCH_REF}/pause`, + ]); + assertPermissionDrift({ command: "branches pause", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -133,6 +194,11 @@ describe("branches pause integration", () => { expect(api.requests.find((r) => r.method === "POST")?.url).toContain( `/v1/projects/${BRANCH_REF}/pause`, ); + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/projects/${VALID_REF}/branches/feat-x`, + `POST ${API}/v1/projects/${BRANCH_REF}/pause`, + ]); + assertPermissionDrift({ command: "branches pause", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -168,4 +234,65 @@ describe("branches pause integration", () => { expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); + + it.effect( + "pauses the branch chosen from the pickers and fills the real linked-project cache", + () => { + const out = mockOutput({ format: "text", promptSelectResponses: [VALID_REF, BRANCH_REF] }); + const api = pickerApi(); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* branchesPause(baseFlags); + + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/projects`, + `GET ${API}/v1/projects/${VALID_REF}/branches`, + `POST ${API}/v1/projects/${BRANCH_REF}/pause`, + `GET ${API}/v1/projects/${VALID_REF}`, + ]); + expect(out.stderrText).toContain(`Selected branch ID: ${BRANCH_REF}`); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "branches pause", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("prompts with the branch listing when the branch argument is an empty string", () => { + const out = mockOutput({ format: "text", promptSelectResponses: [BRANCH_REF] }); + const api = pickerApi(); + const layer = buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + tty: mockTty({ stdinIsTty: true }), + }); + return Effect.gen(function* () { + yield* branchesPause({ ...baseFlags, name: Option.some("") }); + + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/projects/${VALID_REF}/branches`, + `POST ${API}/v1/projects/${BRANCH_REF}/pause`, + ]); + assertPermissionDrift({ command: "branches pause", requests: api.requests }); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md index 8b59c31ba7..9290944788 100644 --- a/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/unpause/SIDE_EFFECTS.md @@ -6,18 +6,21 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after the command runs (in `Effect.ensuring`), unless the file already exists, `.temp/project-ref` names a different project, no access token resolves, or the project lookup is not 200 | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | When | Response | -| ------ | ------------------------------------ | ------------ | -------------------------------------------------------------- | ----------------- | -| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | `{project_ref}` | -| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | `{ref}` | -| `POST` | `/v1/projects/{branch_ref}/restore` | Bearer token | always — final unpause action | none (expect 200) | +| Method | Path | Auth | When | Response | +| ------ | ------------------------------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | `{project_ref}` | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | `{ref}` | +| `POST` | `/v1/projects/{branch_ref}/restore` | Bearer token | always — final unpause action | none (expect 200) | +| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | `[{name, project_ref, …}]` | +| `GET` | `/v1/projects` | Bearer token | no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | `[{id, name, organization_slug, region, …}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | `{ref, name, organization_id, organization_slug}` | ## Environment Variables diff --git a/apps/cli/src/commands/branches/unpause/unpause.command.ts b/apps/cli/src/commands/branches/unpause/unpause.command.ts index 66ede0fb27..fec9be4e19 100644 --- a/apps/cli/src/commands/branches/unpause/unpause.command.ts +++ b/apps/cli/src/commands/branches/unpause/unpause.command.ts @@ -1,6 +1,12 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { branchPrompt } from "../branches.prompt.ts"; +import { branchRef } from "../../../command-internal/branch-ref.resolver.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -22,6 +28,15 @@ export type BranchesUnpauseFlags = CliCommand.Command.Config.Infer branchesUnpause(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), diff --git a/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts b/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts index 79b69717a2..46b20bace2 100644 --- a/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts +++ b/apps/cli/src/commands/branches/unpause/unpause.integration.test.ts @@ -1,22 +1,65 @@ +import { type V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { + VALID_REF, buildTestRuntime, jsonResponse, mockCommandSettings, mockCommandPlatformApi, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import type { BranchesUnpauseFlags } from "./unpause.command.ts"; import { branchesUnpause } from "./unpause.handler.ts"; // 20-lowercase ref (matches V1RestoreAProjectInput.ref plain pattern). const BRANCH_REF = "cccccccccccccccccccc"; +const OTHER_BRANCH_REF = "dddddddddddddddddddd"; +const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; +const API = "https://api.supabase.com"; + +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + +const branchListing = (projectRef: string, name: string) => ({ + id: "11111111-1111-4111-8111-111111111111", + name, + project_ref: projectRef, + parent_project_ref: VALID_REF, + is_default: false, + persistent: false, + status: "MIGRATIONS_PASSED", + created_at: "2026-05-27T01:02:03Z", + updated_at: "2026-05-27T01:02:04Z", + with_data: false, +}); + const tempRoot = useTempWorkdir("supabase-branches-unpause-int-"); +const sequence = (requests: ReadonlyArray<{ method: string; url: string }>) => + requests.map(({ method, url }) => `${method} ${url}`); + interface SetupOpts { readonly restoreStatus?: number; } @@ -51,6 +94,41 @@ describe("branches unpause integration", () => { expect(api.requests.find((r) => r.method === "POST")?.url).toContain( `/v1/projects/${BRANCH_REF}/restore`, ); + expect(sequence(api.requests)).toEqual([`POST ${API}/v1/projects/${BRANCH_REF}/restore`]); + assertPermissionDrift({ command: "branches unpause", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("resolves a UUID through the branch config before unpausing", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => + request.method === "GET" && request.url === `${API}/v1/branches/${BRANCH_UUID}` + ? jsonResponse(request, 200, { + ref: BRANCH_REF, + postgres_version: "15", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: `db.${BRANCH_REF}.supabase.co`, + db_port: 5432, + }) + : jsonResponse(request, 200, null), + ), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + }); + return Effect.gen(function* () { + yield* branchesUnpause({ ...baseFlags, name: Option.some(BRANCH_UUID) }); + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/branches/${BRANCH_UUID}`, + `POST ${API}/v1/projects/${BRANCH_REF}/restore`, + ]); + assertPermissionDrift({ command: "branches unpause", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -77,4 +155,75 @@ describe("branches unpause integration", () => { } }).pipe(Effect.provide(layer)); }); + + it.effect( + "unpauses the branch chosen from the pickers and fills the real linked-project cache", + () => { + const out = mockOutput({ format: "text", promptSelectResponses: [VALID_REF, BRANCH_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "GET" && request.url === `${API}/v1/projects`) { + return jsonResponse(request, 200, [PROJECT]); + } + if (request.method === "GET" && request.url === `${API}/v1/projects/${VALID_REF}`) { + return jsonResponse(request, 200, PROJECT); + } + if ( + request.method === "GET" && + request.url === `${API}/v1/projects/${VALID_REF}/branches` + ) { + return jsonResponse(request, 200, [ + branchListing(OTHER_BRANCH_REF, "other"), + branchListing(BRANCH_REF, "feat-x"), + ]); + } + if ( + request.method === "POST" && + request.url === `${API}/v1/projects/${BRANCH_REF}/restore` + ) { + return jsonResponse(request, 200, null); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* branchesUnpause(baseFlags); + + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/projects`, + `GET ${API}/v1/projects/${VALID_REF}/branches`, + `POST ${API}/v1/projects/${BRANCH_REF}/restore`, + `GET ${API}/v1/projects/${VALID_REF}`, + ]); + expect(out.stderrText).toContain(`Selected branch ID: ${BRANCH_REF}`); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "branches unpause", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md b/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md index 465d927b75..8d84d15cc9 100644 --- a/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/branches/update/SIDE_EFFECTS.md @@ -6,20 +6,23 @@ Same auth fallback chain as every Management-API command. Project-ref discovery ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------------------ | -| `/supabase/.temp/linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | after the command runs (in `Effect.ensuring`), unless the file already exists, `.temp/project-ref` names a different project, no access token resolves, or the project lookup is not 200 | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | When | Request body | Response | -| ------- | ----------------------------------------------- | ------------ | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------ | -| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | none | `{project_ref}` | -| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | none | `{ref}` | -| `PATCH` | `/v1/branches/{branch_id_or_ref}` | Bearer token | always | `{branch_name?, git_branch?, persistent?, status?, notify_url?}` (only set flags emitted) | full `BranchResponse` | -| `GET` | `/v1/projects/{ref}` (on 4xx gated) | Bearer token | upgrade-suggest path | none | `{organization_slug}` | -| `GET` | `/v1/organizations/{slug}/entitlements` (gated) | Bearer token | upgrade-suggest path | none | `[{feature: {key}, hasAccess, ...}]` | +| Method | Path | Auth | When | Request body | Response | +| ------- | ----------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/branches/{name}` | Bearer token | branch input is not a UUID and not a `^[a-z]{20}$` ref pattern | none | `{project_ref}` | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | branch input is a UUID | none | `{ref}` | +| `PATCH` | `/v1/branches/{branch_id_or_ref}` | Bearer token | always | `{branch_name?, git_branch?, persistent?, status?, notify_url?}` (only set flags emitted) | full `BranchResponse` | +| `GET` | `/v1/projects/{ref}` (on 4xx gated) | Bearer token | upgrade-suggest path | none | `{organization_slug}` | +| `GET` | `/v1/organizations/{slug}/entitlements` (gated) | Bearer token | upgrade-suggest path | none | `[{feature: {key}, hasAccess, ...}]` | +| `GET` | `/v1/projects/{ref}/branches` | Bearer token | branch argument omitted or empty and stdin is a TTY (branch picker) | none | `[{name, project_ref, …}]` | +| `GET` | `/v1/projects` | Bearer token | no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format (project picker) | none | `[{id, name, organization_slug, region, …}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | linked-project cache miss only (best-effort, after the command) | none | `{ref, name, organization_id, organization_slug}` | ## Environment Variables diff --git a/apps/cli/src/commands/branches/update/update.command.ts b/apps/cli/src/commands/branches/update/update.command.ts index 29c2a265c9..2370bd8293 100644 --- a/apps/cli/src/commands/branches/update/update.command.ts +++ b/apps/cli/src/commands/branches/update/update.command.ts @@ -1,6 +1,13 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { branchPrompt } from "../branches.prompt.ts"; +import { branchRef } from "../../../command-internal/branch-ref.resolver.ts"; +import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -49,6 +56,19 @@ export type BranchesUpdateFlags = CliCommand.Command.Config.Infer export const branchesUpdateCommand = Command.make("update", config).pipe( Command.withDescription("Update a preview branch by its name or ID."), Command.withShortDescription("Update a preview branch"), + withPermissions( + compose( + { + operations: [{ operationId: "v1-update-a-branch-config", kind: "required" }], + noApiEffectFlags: ["name", "git-branch", "persistent", "status", "notify-url"], + }, + projectRefPicker, + branchPrompt, + branchRef, + upgradeSuggestion, + linkedProjectCache, + ), + ), Command.withHandler((flags) => branchesUpdate(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"], config }), diff --git a/apps/cli/src/commands/branches/update/update.integration.test.ts b/apps/cli/src/commands/branches/update/update.integration.test.ts index e513badd0d..a1a35e0e81 100644 --- a/apps/cli/src/commands/branches/update/update.integration.test.ts +++ b/apps/cli/src/commands/branches/update/update.integration.test.ts @@ -1,9 +1,13 @@ -import { type V1UpdateABranchConfigOutput } from "@supabase/api/effect"; +import { + type V1ListAllProjectsOutput, + type V1UpdateABranchConfigOutput, +} from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { + VALID_REF, buildTestRuntime, jsonResponse, mockCommandSettings, @@ -12,6 +16,8 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import type { BranchesUpdateFlags } from "./update.command.ts"; import { branchesUpdate } from "./update.handler.ts"; @@ -76,8 +82,33 @@ const BRANCH_CONFIG = { db_port: 5432, }; +const OTHER_BRANCH_REF = "dddddddddddddddddddd"; +const API = "https://api.supabase.com"; + +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + const tempRoot = useTempWorkdir("supabase-branches-update-int-"); +const sequence = (requests: ReadonlyArray<{ method: string; url: string }>) => + requests.map(({ method, url }) => `${method} ${url}`); + interface SetupOpts { readonly format?: "text" | "json" | "stream-json"; readonly outputFlag?: "env" | "pretty" | "json" | "toml" | "yaml"; @@ -145,7 +176,7 @@ const baseFlags: BranchesUpdateFlags = { describe("branches update integration", () => { it.effect("updates a branch with --name and emits 'Updated preview branch:' to stderr", () => { - const { layer, out } = setup(); + const { layer, out, api } = setup(); return Effect.gen(function* () { yield* branchesUpdate({ ...baseFlags, @@ -154,6 +185,16 @@ describe("branches update integration", () => { }); expect(out.stderrText).toContain("Updated preview branch:"); expect(out.stdoutText).toContain("renamed"); + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/branches/${BRANCH_UUID}`, + `PATCH ${API}/v1/branches/${BRANCH_UUID}`, + ]); + assertPermissionDrift({ + command: "branches update", + activeFlags: { name: true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -175,6 +216,11 @@ describe("branches update integration", () => { persistent: true, notify_url: "https://hook.example.com", }); + assertPermissionDrift({ + command: "branches update", + activeFlags: { name: true, "git-branch": true, persistent: true, "notify-url": true }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); @@ -264,9 +310,8 @@ describe("branches update integration", () => { const analytics = mockAnalytics(); const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); - // suggestUpgrade bypasses the typed API client (so cli-e2e replay fixtures with - // `__PROJECT_REF__` placeholders don't trip strict schema decode), so all three URLs - // route through the same mockCommandPlatformApi handler the production HttpClient uses. + // The gate's follow-up project and entitlements GETs share the PATCH's + // mockCommandPlatformApi handler. const apiMock = mockCommandPlatformApi({ handler: (request) => Effect.sync(() => { @@ -316,6 +361,16 @@ describe("branches update integration", () => { r.url.endsWith(`/v1/organizations/${ORG_SLUG}/entitlements`), ); expect(entitlementsCall).toBeDefined(); + expect(sequence(apiMock.requests)).toEqual([ + `PATCH ${API}/v1/branches/${BRANCH_REF}`, + `GET ${API}/v1/projects/${BRANCH_REF}`, + `GET ${API}/v1/organizations/${ORG_SLUG}/entitlements`, + ]); + assertPermissionDrift({ + command: "branches update", + activeFlags: { persistent: true }, + requests: apiMock.requests, + }); expect(analytics.captured).toEqual([ { event: "cli_upgrade_suggested", @@ -326,4 +381,74 @@ describe("branches update integration", () => { }).pipe(Effect.provide(layer)); }, ); + + it.effect( + "updates the branch chosen from the pickers and fills the real linked-project cache", + () => { + const out = mockOutput({ format: "text", promptSelectResponses: [VALID_REF, BRANCH_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "GET" && request.url === `${API}/v1/projects`) { + return jsonResponse(request, 200, [PROJECT]); + } + if (request.method === "GET" && request.url === `${API}/v1/projects/${VALID_REF}`) { + return jsonResponse(request, 200, PROJECT); + } + if ( + request.method === "GET" && + request.url === `${API}/v1/projects/${VALID_REF}/branches` + ) { + return jsonResponse(request, 200, [ + { ...UPDATED, name: "other", project_ref: OTHER_BRANCH_REF }, + { ...UPDATED, name: "feat-x" }, + ]); + } + if (request.method === "PATCH" && request.url === `${API}/v1/branches/${BRANCH_REF}`) { + return jsonResponse(request, 200, UPDATED); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* branchesUpdate({ ...baseFlags, name: Option.some("renamed") }); + + expect(sequence(api.requests)).toEqual([ + `GET ${API}/v1/projects`, + `GET ${API}/v1/projects/${VALID_REF}/branches`, + `PATCH ${API}/v1/branches/${BRANCH_REF}`, + `GET ${API}/v1/projects/${VALID_REF}`, + ]); + expect(out.stderrText).toContain("Updated preview branch:"); + expect(out.stdoutText).toContain("renamed"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "branches update", + activeFlags: { name: true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/commands/completion/bash/bash.command.ts b/apps/cli/src/commands/completion/bash/bash.command.ts index b1e3db687e..f045e1d091 100644 --- a/apps/cli/src/commands/completion/bash/bash.command.ts +++ b/apps/cli/src/commands/completion/bash/bash.command.ts @@ -5,6 +5,7 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { CompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { completionBash } from "./bash.handler.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = { noDescriptions: CompletionNoDescriptionsFlagDef, @@ -26,6 +27,7 @@ export const completionBashCommand = Command.make("bash", config).pipe( "You will need to start a new shell for this setup to take effect.", ), Command.withShortDescription("Generate the autocompletion script for bash"), + withPermissions({ operations: [], noApiEffectFlags: ["no-descriptions"] }), Command.withHandler((flags) => completionBash(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/completion/fish/fish.command.ts b/apps/cli/src/commands/completion/fish/fish.command.ts index 594a89899f..1913bc05d6 100644 --- a/apps/cli/src/commands/completion/fish/fish.command.ts +++ b/apps/cli/src/commands/completion/fish/fish.command.ts @@ -5,6 +5,7 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { CompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { completionFish } from "./fish.handler.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = { noDescriptions: CompletionNoDescriptionsFlagDef, @@ -21,6 +22,7 @@ export const completionFishCommand = Command.make("fish", config).pipe( "You will need to start a new shell for this setup to take effect.", ), Command.withShortDescription("Generate the autocompletion script for fish"), + withPermissions({ operations: [], noApiEffectFlags: ["no-descriptions"] }), Command.withHandler((flags) => completionFish(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/completion/powershell/powershell.command.ts b/apps/cli/src/commands/completion/powershell/powershell.command.ts index 7afb1a75e5..b8197ec403 100644 --- a/apps/cli/src/commands/completion/powershell/powershell.command.ts +++ b/apps/cli/src/commands/completion/powershell/powershell.command.ts @@ -5,6 +5,7 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { CompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { completionPowershell } from "./powershell.handler.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = { noDescriptions: CompletionNoDescriptionsFlagDef, @@ -20,6 +21,7 @@ export const completionPowershellCommand = Command.make("powershell", config).pi "to your powershell profile.", ), Command.withShortDescription("Generate the autocompletion script for powershell"), + withPermissions({ operations: [], noApiEffectFlags: ["no-descriptions"] }), Command.withHandler((flags) => completionPowershell(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/completion/zsh/zsh.command.ts b/apps/cli/src/commands/completion/zsh/zsh.command.ts index b80c3e3871..08d969c0ad 100644 --- a/apps/cli/src/commands/completion/zsh/zsh.command.ts +++ b/apps/cli/src/commands/completion/zsh/zsh.command.ts @@ -5,6 +5,7 @@ import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.lay import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { CompletionNoDescriptionsFlagDef } from "../completion.flags.ts"; import { completionZsh } from "./zsh.handler.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = { noDescriptions: CompletionNoDescriptionsFlagDef, @@ -27,6 +28,7 @@ export const completionZshCommand = Command.make("zsh", config).pipe( "You will need to start a new shell for this setup to take effect.", ), Command.withShortDescription("Generate the autocompletion script for zsh"), + withPermissions({ operations: [], noApiEffectFlags: ["no-descriptions"] }), Command.withHandler((flags) => completionZsh(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/config/diff/diff.command.ts b/apps/cli/src/commands/config/diff/diff.command.ts index 68910bd7c6..d0f54c1d84 100644 --- a/apps/cli/src/commands/config/diff/diff.command.ts +++ b/apps/cli/src/commands/config/diff/diff.command.ts @@ -5,6 +5,10 @@ import { Command, Flag } from "effect/cli"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { configTarget } from "../../../command-internal/project-target.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { GLOBAL_OUTPUT_FORMATS } from "../../../command-internal/global-flags.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; @@ -64,6 +68,16 @@ export const configDiffCommand = Command.make("diff", config).pipe( description: "Diff against the 'staging' branch, exiting 2 on drift", }, ]), + withPermissions( + compose( + configTarget, + { + operations: [{ operationId: "v2-get-project-config", kind: "required" }], + noApiEffectFlags: ["exit-code"], + }, + linkedProjectCache, + ), + ), Command.withHandler(configDiffHandler), Command.provide(managementApiRuntimeLayer(["config", "diff"])), Command.provide(cliConfigValuesLayer), diff --git a/apps/cli/src/commands/config/diff/diff.integration.test.ts b/apps/cli/src/commands/config/diff/diff.integration.test.ts index 542adc9052..52a82fdf8a 100644 --- a/apps/cli/src/commands/config/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/config/diff/diff.integration.test.ts @@ -1,4 +1,5 @@ import { BunServices } from "@effect/platform-bun"; +import { type V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Schema, Stdio } from "effect"; @@ -8,6 +9,7 @@ import { mockOutput, mockProcessControl, mockRuntimeInfo, + mockTty, } from "../../../../tests/helpers/mocks.ts"; import { v2ProjectConfigResponse } from "../../../../tests/helpers/config-fixtures.ts"; import { @@ -23,6 +25,8 @@ import { useTempWorkdir, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { resolveWorkdir } from "../../../config/command-settings.layer.ts"; import { GLOBAL_OUTPUT_FORMATS } from "../../../command-internal/global-flags.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; @@ -37,6 +41,7 @@ const BACKDATED_MTIME_SECONDS = 1_577_836_800; const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; const BRANCH_REF = "cccccccccccccccccccc"; +const API = "https://api.supabase.com"; const writeProjectFile = Effect.fnUntraced(function* (name: string, contents: string) { const fs = yield* FileSystem.FileSystem; @@ -47,6 +52,7 @@ const writeProjectFile = Effect.fnUntraced(function* (name: string, contents: st }); const jsonText = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeJsonText = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); /** Schema-valid v2 project-config body whose managed values all sit at the local schema * defaults, so an empty config.toml diffs clean against it. */ @@ -61,6 +67,23 @@ const decodeChanges = Schema.decodeUnknownEffect( ), ); +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + /** V1GetABranch body for the branch-name `--project-ref` lookup. */ const BRANCH_BY_NAME = { id: BRANCH_UUID, @@ -172,7 +195,7 @@ const noFlags = { describe("config diff integration", () => { it.effect("reports drift against the linked project without touching the config file", () => { - const { layer, out, processControl, telemetry, linkedProjectCache } = setup({ + const { layer, out, processControl, telemetry, linkedProjectCache, api } = setup({ toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', }); return Effect.gen(function* () { @@ -202,6 +225,7 @@ describe("config diff integration", () => { expect(processControl.exitCode).toBeUndefined(); expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cachedRef).toBe(VALID_REF); + assertPermissionDrift({ command: "config diff", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -283,12 +307,18 @@ describe("config diff integration", () => { }); it.effect("--exit-code sets exit 2 when differences are found", () => { - const { layer, processControl } = setup({ + const { layer, processControl, api } = setup({ toml: 'project_id = "test"\n[api]\nmax_rows = 500\n', }); return Effect.gen(function* () { yield* configDiff({ ...noFlags, exitCode: true }); expect(processControl.exitCode).toBe(2); + assertPermissionDrift({ + command: "config diff", + activeFlags: { "exit-code": true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -547,6 +577,11 @@ describe("config diff integration", () => { true, ); expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); + assertPermissionDrift({ + command: "config diff", + activeFlags: { "project-ref": true }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); @@ -576,6 +611,11 @@ describe("config diff integration", () => { true, ); expect(urls.some((url) => url.includes(`/v1/projects/${BRANCH_REF}/`))).toBe(false); + assertPermissionDrift({ + command: "config diff", + activeFlags: { "project-ref": true }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }, ); @@ -595,6 +635,11 @@ describe("config diff integration", () => { expect(out.stderrText).toContain( `Comparing against branch ${BRANCH_UUID} (project ref ${BRANCH_REF})`, ); + assertPermissionDrift({ + command: "config diff", + activeFlags: { "project-ref": true }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); @@ -608,6 +653,12 @@ describe("config diff integration", () => { const urls = api.requests.map((request) => request.url); expect(urls.some((url) => url.includes("/branches/"))).toBe(false); expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); + assertPermissionDrift({ + command: "config diff", + activeFlags: { "project-ref": true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -880,7 +931,7 @@ describe("config diff integration", () => { }); it.effect("a remote config error status maps to the read status error", () => { - const { layer } = setup({ + const { layer, api } = setup({ toml: 'project_id = "test"\n', v2: { status: 403, body: { message: "forbidden" } }, }); @@ -894,6 +945,10 @@ describe("config diff integration", () => { expect(causeText).toContain("Access denied"); expect(causeText).toContain(VALID_REF); } + expect( + api.requests.map((request) => `${request.method} ${new URL(request.url).pathname}`), + ).toEqual([`GET /v2/projects/${VALID_REF}/config`]); + assertPermissionDrift({ command: "config diff", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -1294,6 +1349,64 @@ describe("config diff integration", () => { expect(api.requests.some((request) => request.url.includes("/v2/projects/"))).toBe(false); }).pipe(Effect.provide(layer)); }); + + it.effect( + "diffs the project chosen from the picker and fills the real linked-project cache", + () => { + const out = mockOutput({ format: "text", promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "GET" && request.url === `${API}/v1/projects`) { + return jsonResponse(request, 200, [PROJECT]); + } + if (request.method === "GET" && request.url === `${API}/v1/projects/${VALID_REF}`) { + return jsonResponse(request, 200, PROJECT); + } + if (request.url === `${API}/v2/projects/${VALID_REF}/config`) { + return jsonResponse(request, 200, v2Response()); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = Layer.merge( + buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }), + cliConfigValuesTestLayer, + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* writeProjectFile("config.toml", 'project_id = "test"\n'); + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* configDiff(noFlags); + + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${API}/v1/projects`, + `GET ${API}/v2/projects/${VALID_REF}/config`, + `GET ${API}/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain("No config differences found."); + expect(yield* decodeJsonText(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "config diff", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); }); describe("config diff telemetry wiring", () => { diff --git a/apps/cli/src/commands/config/pull/pull.command.ts b/apps/cli/src/commands/config/pull/pull.command.ts index afe66499c1..9da13f0ee0 100644 --- a/apps/cli/src/commands/config/pull/pull.command.ts +++ b/apps/cli/src/commands/config/pull/pull.command.ts @@ -5,6 +5,11 @@ import { Command, Flag } from "effect/cli"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { configPullRun } from "../../../command-internal/config-pull-run.ts"; +import { configTarget } from "../../../command-internal/project-target.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { GLOBAL_OUTPUT_FORMATS } from "../../../command-internal/global-flags.ts"; @@ -77,6 +82,14 @@ export const configPullCommand = Command.make("pull", config).pipe( description: "Preview the changes without writing the config file", }, ]), + withPermissions( + compose( + configTarget, + configPullRun, + { operations: [], noApiEffectFlags: ["remote-label", "dry-run", "force"] }, + linkedProjectCache, + ), + ), Command.withHandler(configPullHandler), // `stdinLayer`: the apply confirmation reads piped stdin via `promptYesNo` // on a non-TTY stdin. diff --git a/apps/cli/src/commands/config/pull/pull.integration.test.ts b/apps/cli/src/commands/config/pull/pull.integration.test.ts index e77747e51e..7bcf2433b1 100644 --- a/apps/cli/src/commands/config/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/config/pull/pull.integration.test.ts @@ -1,4 +1,5 @@ import { BunServices } from "@effect/platform-bun"; +import { type V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; import { Cause, @@ -38,6 +39,12 @@ import { useTempWorkdir, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; +import { + assertBlockDrift, + assertPermissionDrift, +} from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; +import { configPullRun } from "../../../command-internal/config-pull-run.ts"; import { GLOBAL_OUTPUT_FORMATS, YesFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { @@ -56,6 +63,7 @@ const tempRoot = useTempWorkdir("supabase-config-pull-int-"); const jsonRecord = Schema.Record(Schema.String, Schema.Unknown); const asJsonRecord = Schema.decodeUnknownSync(jsonRecord); const decodeJsonRecord = Schema.decodeUnknownEffect(jsonRecord); +const decodeJsonText = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); const decodeJsonRecords = Schema.decodeUnknownEffect(Schema.Array(jsonRecord)); const decodeChanges = Schema.decodeUnknownEffect( Schema.Array( @@ -66,9 +74,27 @@ const decodeChanges = Schema.decodeUnknownEffect( // A past mtime for "untouched file" checks, so a rewrite within the same millisecond still shows. const BACKDATED_MTIME_SECONDS = 1_577_836_800; +const API = "https://api.supabase.com"; const BRANCH_REF = "cccccccccccccccccccc"; const BRANCH_UUID = "11111111-1111-4111-8111-111111111111"; +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; + const projectFilePath = Effect.fnUntraced(function* (name: string) { const path = yield* Path.Path; return path.join(tempRoot.current, "supabase", name); @@ -348,6 +374,9 @@ interface SetupOpts { readonly linked?: boolean; /** Overrides `cliSettings.projectId` directly — takes precedence over `linked`. */ readonly projectId?: Option.Option; + /** Serves the project list and project lookup, answers the project picker with `VALID_REF`, and + * writes the linked-project cache through the real cache layer. */ + readonly pickerAndRealCache?: boolean; readonly yes?: boolean; readonly stdinIsTty?: boolean; readonly confirm?: ReadonlyArray; @@ -369,7 +398,11 @@ function setup(opts: SetupOpts = {}) { opts.dotenv === undefined ? Effect.void : writeProjectFile(".env", opts.dotenv), ]), ).pipe(Layer.provide(BunServices.layer)); - const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm }); + const out = mockOutput({ + format: opts.format ?? "text", + promptConfirmResponses: opts.confirm, + ...(opts.pickerAndRealCache === true ? { promptSelectResponses: [VALID_REF] } : {}), + }); const outputLayer = opts.confirmSideEffect === undefined ? out.layer @@ -377,6 +410,13 @@ function setup(opts: SetupOpts = {}) { const api = mockCommandPlatformApi({ handler: (request) => { const url = request.url; + if (opts.pickerAndRealCache === true && request.method === "GET") { + if (url === `${API}/v1/projects`) + return Effect.succeed(jsonResponse(request, 200, [PROJECT])); + if (url === `${API}/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, PROJECT)); + } + } if (url.includes("/v2/projects/")) { if (opts.v2 === "fail") { return Effect.fail(transportFailure(request)); @@ -412,6 +452,15 @@ function setup(opts: SetupOpts = {}) { const telemetry = mockTelemetryStateTracked(); const linkedProjectCache = mockLinkedProjectCacheTracked(); const processControl = mockProcessControl(); + const cliSettings = mockCommandSettings({ + workdir: opts.workdir ?? tempRoot.current, + explicitWorkdir: opts.explicitWorkdir ?? false, + ...(opts.projectId !== undefined + ? { projectId: opts.projectId } + : opts.linked === false + ? { projectId: Option.none() } + : {}), + }); const gitStatus = mockGitStatusSpawner({ dirty: opts.gitDirty, spawnFails: opts.gitSpawnFails, @@ -420,18 +469,13 @@ function setup(opts: SetupOpts = {}) { buildTestRuntime({ out: { layer: outputLayer }, api, - cliSettings: mockCommandSettings({ - workdir: opts.workdir ?? tempRoot.current, - explicitWorkdir: opts.explicitWorkdir ?? false, - ...(opts.projectId !== undefined - ? { projectId: opts.projectId } - : opts.linked === false - ? { projectId: Option.none() } - : {}), - }), + cliSettings, runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), telemetry: telemetry.layer, - linkedProjectCache: linkedProjectCache.layer, + linkedProjectCache: + opts.pickerAndRealCache === true + ? realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }) + : linkedProjectCache.layer, processControl, tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? false, stdoutIsTty: false }), outputFlag: opts.outputFlag === undefined ? Option.none() : Option.some(opts.outputFlag), @@ -467,7 +511,10 @@ describe("config pull integration", () => { "root-target single-property update writes the file with exactly one changed line", () => { const before = 'project_id = "test"\n[api]\nmax_rows = 500\n'; - const { layer, out, telemetry, linkedProjectCache } = setup({ toml: before, yes: true }); + const { layer, out, telemetry, linkedProjectCache, api } = setup({ + toml: before, + yes: true, + }); return Effect.gen(function* () { yield* configPull(noFlags); const after = yield* readConfig; @@ -477,13 +524,48 @@ describe("config pull integration", () => { expect(out.stdoutText).toContain("1 change written."); expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cachedRef).toBe(VALID_REF); + assertPermissionDrift({ command: "config pull", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect( + "pulls from the project chosen in the picker and fills the real linked-project cache", + () => { + const before = 'project_id = "test"\n[api]\nmax_rows = 500\n'; + const { layer, out, api } = setup({ + toml: before, + yes: true, + linked: false, + stdinIsTty: true, + pickerAndRealCache: true, + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* configPull(noFlags); + + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${API}/v1/projects`, + `GET ${API}/v2/projects/${VALID_REF}/config`, + `GET ${API}/v1/projects/${VALID_REF}`, + ]); + expect(yield* readConfig).toContain("max_rows = 1000"); + expect(out.stdoutText).toContain("1 change written."); + expect(yield* decodeJsonText(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "config pull", requests: api.requests, exact: true }); + assertBlockDrift(configPullRun, { requests: api.requests.slice(1, 2), exact: true }); }).pipe(Effect.provide(layer)); }, ); it.effect("--dry-run leaves the file untouched and reports dry_run in the payload", () => { const before = 'project_id = "test"\n[api]\nmax_rows = 500\n'; - const { layer, out } = setup({ toml: before, format: "json", yes: true }); + const { layer, out, api } = setup({ toml: before, format: "json", yes: true }); return Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const path = yield* projectFilePath("config.toml"); @@ -498,6 +580,12 @@ describe("config pull integration", () => { expect(data["wrote"]).toBe(false); const changes = yield* decodeJsonRecords(data["changes"]); expect(changes.every((change) => change["written"] === false)).toBe(true); + assertPermissionDrift({ + command: "config pull", + activeFlags: { "dry-run": true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -1654,6 +1742,11 @@ describe("config pull integration", () => { true, ); expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); + assertPermissionDrift({ + command: "config pull", + activeFlags: { "project-ref": true }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); @@ -1684,6 +1777,11 @@ describe("config pull integration", () => { // A UUID target creates a new `[remotes.*]` block falling back to the // resolved project ref as the label (no branch name to use instead). expect(yield* readConfig).toContain(`[remotes.${BRANCH_REF}]`); + assertPermissionDrift({ + command: "config pull", + activeFlags: { "project-ref": true }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); @@ -1698,6 +1796,12 @@ describe("config pull integration", () => { const urls = api.requests.map((request) => request.url); expect(urls.some((url) => url.includes("/branches/"))).toBe(false); expect(urls.some((url) => url.includes(`/v2/projects/${BRANCH_REF}/config`))).toBe(true); + assertPermissionDrift({ + command: "config pull", + activeFlags: { "project-ref": true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -1885,7 +1989,7 @@ describe("config pull integration", () => { }); it.effect("a 403 on the config read names the sanitized ref", () => { - const { layer } = setup({ + const { layer, api } = setup({ toml: 'project_id = "test"\n', v2: { status: 403, body: { message: "forbidden" } }, }); @@ -1898,6 +2002,7 @@ describe("config pull integration", () => { expect(causeText).toContain("Access denied"); expect(causeText).toContain(VALID_REF); } + assertPermissionDrift({ command: "config pull", requests: api.requests }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md index 2bc89794d6..b2fb440aa3 100644 --- a/apps/cli/src/commands/config/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/config/push/SIDE_EFFECTS.md @@ -44,8 +44,8 @@ declines the confirmation prompt. | -2 | branch resolution (name) | GET | `/v1/projects/{parent_ref}/branches/{name}` | 200 | only when `--project-ref` names a branch by name (CLI-2289); `parent_ref` from the currently linked project | | -2 | branch resolution (UUID) | GET | `/v1/branches/{id}` | 200 | only when `--project-ref` is a UUID (CLI-2289); needs no linked project | | -1 | target detection | GET | `/v1/projects/{ref}` | 200 or 404 | CLI-2168: 200 = plain project (its `name` is shown); 404 = `ref` is a preview branch. Wrapped in a `"Checking project..."` task and bounded at 5s. Entirely best-effort/diagnostic: a TIMEOUT, a transport failure, or any OTHER status degrades to an "unknown" target (never silently "project" — that would skip the confirmation gate for a real branch — and never a hard failure that aborts an otherwise-successful push, e.g. for a scoped token that can write service config but can't read the project record). Skipped entirely when `--project-ref` already named a branch by name/UUID above. | -| -1 | branch name lookup | GET | `/v1/projects/{parent_ref}/branches` | 200 | only on the 404 path above, or a UUID-resolved target (never when the probe degrades to "unknown" — that never attempts recovery) — and only when a candidate parent is known (from `--project-ref` or `.temp/linked-project.json`); best-effort, 5s-bounded — a failure only omits the branch's name/parent from the echoed message, it never fails the command | -| 0 | cost matrix | GET | `/v1/projects/{ref}/billing/addons` | 200 | raw HTTP; cost map for 1-variant addons; unchanged, still fetched first | +| -1 | branch name lookup | GET | `/v1/projects/{parent_ref}/branches` | 200 | only on the 404 path above, or a UUID-resolved target (never when the probe degrades to "unknown" — that never attempts recovery) — and only when `.temp/linked-project.json` names a different, ref-shaped project to treat as the parent (a branch-name `--project-ref` never triggers it); best-effort, 5s-bounded — a failure only omits the branch's name/parent from the echoed message, it never fails the command | +| 0 | cost matrix | GET | `/v1/projects/{ref}/billing/addons` | 200 | typed client; cost map for 1-variant addons; fetched after the target probe and the branch confirmation gate | | 1 | effective project config | GET | `/v2/projects/{ref}/config` | 200 | the ONLY read this command makes — see below | | 2 | api | PATCH | `/v1/projects/{ref}/postgrest` | 200 | only when `api` has a pushable change, its response block was returned, and the change is kept | | 3 | db.settings | PUT | `/v1/projects/{ref}/config/database/postgres` | 200 | same conditions (this resource's own local gate is "always on") | @@ -53,7 +53,7 @@ declines the confirmation prompt. | 5 | db.ssl_enforcement | PUT | `/v1/projects/{ref}/ssl-enforcement` | 200 | same conditions, plus `[db.ssl_enforcement]` declared locally | | 6 | auth | PATCH | `/v1/projects/{ref}/config/auth` | 2xx | same conditions (this resource's own local gate is "always on" too, CLI-2314 — `auth.enabled` only controls the local GoTrue Docker service, not this write); MFA phone/webauthn gated by an addon cost prompt when `verify_enabled` or `enroll_enabled` is being turned on and the add-on is not already active on the project | | 7 | storage | PATCH | `/v1/projects/{ref}/config/storage` | 2xx | same conditions (this resource's own local gate is "always on" too, CLI-2314 — `storage.enabled` only controls the local Storage Docker service, not this write) | -| 8 | experimental.webhooks | POST | `/v1/projects/{ref}/database/webhooks/enable` | 2xx | only if local `webhooks.enabled`; no GET/diff; a kept enable reports `changes: [["experimental","webhooks","enabled"]]` (no config-diff path backs it) so it still counts toward the pushed-property total | +| 8 | experimental.webhooks | POST | `/v1/projects/{ref}/database/webhooks/enable` | 2xx | only if local `experimental.webhooks.enabled`; no GET/diff; a kept enable reports `changes: [["experimental","webhooks","enabled"]]` (no config-diff path backs it) so it still counts toward the pushed-property total | **Row 1 is the only per-resource `GET` this command makes** — no per-service `GET /v1/…` request remains anywhere in this table; row 1's single response diff --git a/apps/cli/src/commands/config/push/push.command.ts b/apps/cli/src/commands/config/push/push.command.ts index 9ef706092b..9457d9a690 100644 --- a/apps/cli/src/commands/config/push/push.command.ts +++ b/apps/cli/src/commands/config/push/push.command.ts @@ -6,6 +6,11 @@ import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts import { PROJECT_REF_PATTERN } from "../../../config/project-ref.service.ts"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withContext } from "../../../command-internal/command-permissions/model.ts"; +import { branchTarget } from "../../../command-internal/branch-target.ts"; +import { configTarget } from "../../../command-internal/project-target.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; @@ -60,6 +65,64 @@ export const configPushCommand = Command.make("push", config).pipe( description: "Push local config to the 'staging' branch", }, ]), + withPermissions( + compose( + configTarget, + { + operations: [ + { + operationId: "v1-get-project", + kind: "best-effort", + context: "--project-ref is absent, empty or a 20-letter project ref (the branch probe)", + }, + { operationId: "v1-list-project-addons", kind: "required" }, + { operationId: "v2-get-project-config", kind: "required" }, + { + operationId: "v1-update-postgrest-service-config", + kind: "required", + context: "config.toml's [api] settings differ from the remote's", + }, + { + operationId: "v1-update-postgres-config", + kind: "required", + context: "config.toml's [db.settings] differ from the remote's", + }, + { + operationId: "v1-update-network-restrictions", + kind: "required", + context: + "config.toml sets [db.network_restrictions] enabled = true and its rules differ from the remote's", + }, + { + operationId: "v1-update-ssl-enforcement-config", + kind: "required", + context: "config.toml declares [db.ssl_enforcement] and it differs from the remote's", + }, + { + operationId: "v1-update-auth-service-config", + kind: "required", + context: + "config.toml's [auth] settings, a referenced email template file, or a resolved auth secret differ from the remote's", + }, + { + operationId: "v1-update-storage-config", + kind: "required", + context: "config.toml's [storage] settings differ from the remote's", + }, + { + operationId: "v1-enable-database-webhook", + kind: "required", + context: "config.toml sets [experimental.webhooks] enabled = true", + }, + ], + }, + withContext( + branchTarget, + "the target is a branch (a UUID --project-ref, or the probe returned 404) and supabase/.temp/linked-project.json names a different project", + ), + linkedProjectCache, + ), + ), Command.withHandler(configPushHandler), Command.provide(Layer.mergeAll(managementApiRuntimeLayer(["config", "push"]), stdinLayer)), Command.provide(cliConfigValuesLayer), diff --git a/apps/cli/src/commands/config/push/push.cost-matrix.ts b/apps/cli/src/commands/config/push/push.cost-matrix.ts index 97bab8e821..1afacf0b32 100644 --- a/apps/cli/src/commands/config/push/push.cost-matrix.ts +++ b/apps/cli/src/commands/config/push/push.cost-matrix.ts @@ -1,11 +1,7 @@ -import { Effect, Predicate } from "effect"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import { Effect } from "effect"; -import { CommandSettings } from "../../../config/command-settings.service.ts"; -import { sanitizeErrorBody } from "../../../command-internal/http-errors.ts"; -import { requestWithAuth } from "../../../command-internal/raw-http.ts"; -import { resolveAccessToken } from "../../../command-internal/resolve-token.ts"; +import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; +import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { ConfigPushListAddonsNetworkError, ConfigPushListAddonsStatusError, @@ -18,58 +14,23 @@ export interface CostItem { readonly price: string; } +const mapListAddonsError = mapHttpError({ + networkError: ConfigPushListAddonsNetworkError, + statusError: ConfigPushListAddonsStatusError, + networkMessage: (cause) => `failed to list addons: ${cause}`, + statusMessage: (status, body) => `unexpected list addons status ${status}: ${body}`, +}); + /** - * GETs `/v1/projects/{ref}/billing/addons` and builds a map of `addon.type` → - * `{ name, price }` for every addon that has exactly one variant. - * - * Uses raw HTTP rather than the typed client: the generated - * `available_addons[].type` is a closed enum (`custom_domain | - * compute_instance | …`) that rejects values accepted as a plain string - * (e.g. the `"api"` GraphQL addon). Mirrors the `sso add` / - * `postgres-config` raw-HTTP precedent. + * Lists the project's addons and builds a map of `addon.type` → `{ name, price }` for every + * addon that has exactly one variant. */ export const getCostMatrix = Effect.fn("config.push.fetchCostMatrix")(function* (ref: string) { - const httpClient = yield* HttpClient.HttpClient; - const cliSettings = yield* CommandSettings; - const tokenOpt = yield* resolveAccessToken; - - const request = requestWithAuth( - HttpClientRequest.get(`${cliSettings.apiUrl}/v1/projects/${ref}/billing/addons`), - tokenOpt, - cliSettings.userAgent, - ); - - const response = yield* httpClient.execute(request).pipe( - Effect.mapError( - (cause) => - new ConfigPushListAddonsNetworkError({ - message: `failed to list addons: ${cause.reason.description ?? cause.reason._tag}`, - }), - ), - ); - - if (response.status !== 200) { - const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - const body = sanitizeErrorBody(rawBody); - return yield* new ConfigPushListAddonsStatusError({ - status: response.status, - body, - message: `unexpected list addons status ${response.status}: ${body}`, - }); - } - - const rawBody = yield* response.text; - const parsed = yield* Effect.try({ - try: () => parseAddonsBody(rawBody), - catch: (cause) => - new ConfigPushListAddonsNetworkError({ - message: `failed to list addons: ${String(cause)}`, - decode: true, - }), - }); + const api = yield* CommandPlatformApi; + const addons = yield* api.v1.listProjectAddons({ ref }).pipe(Effect.catch(mapListAddonsError)); const costMatrix = new Map(); - for (const addon of readAddons(parsed)) { + for (const addon of addons.available_addons) { const variant = addon.variants.length === 1 ? addon.variants[0] : undefined; if (variant !== undefined) { costMatrix.set(addon.type, { name: variant.name, price: variant.price.description }); @@ -78,43 +39,3 @@ export const getCostMatrix = Effect.fn("config.push.fetchCostMatrix")(function* yield* Effect.annotateCurrentSpan("addon.count", costMatrix.size); return costMatrix; }); - -// Native `JSON.parse` keeps the runtime's syntax-error text in the `failed to list addons` message. -function parseAddonsBody(text: string): unknown { - return JSON.parse(text); -} - -interface ParsedAddon { - readonly type: string; - readonly variants: ReadonlyArray<{ - readonly name: string; - readonly price: { readonly description: string }; - }>; -} - -/** Tolerantly extracts `available_addons` with a string `type`, since the API response itself - * uses a plain string, not the enum the generated client declares. */ -function readAddons(parsed: unknown): ReadonlyArray { - if (typeof parsed !== "object" || parsed === null) return []; - const available = Predicate.hasProperty(parsed, "available_addons") - ? parsed.available_addons - : undefined; - if (!Array.isArray(available)) return []; - const addons: Array = []; - for (const entry of available) { - if (typeof entry !== "object" || entry === null) continue; - const type = Predicate.hasProperty(entry, "type") ? entry.type : undefined; - const variantsRaw = Predicate.hasProperty(entry, "variants") ? entry.variants : undefined; - if (typeof type !== "string" || !Array.isArray(variantsRaw)) continue; - const variants = variantsRaw.map((v) => { - const name: unknown = v.name; - const description: unknown = v.price?.description; - return { - name: typeof name === "string" ? name : "", - price: { description: typeof description === "string" ? description : "" }, - }; - }); - addons.push({ type, variants }); - } - return addons; -} diff --git a/apps/cli/src/commands/config/push/push.e2e.test.ts b/apps/cli/src/commands/config/push/push.e2e.test.ts index c32edccdf8..c6bb580d3b 100644 --- a/apps/cli/src/commands/config/push/push.e2e.test.ts +++ b/apps/cli/src/commands/config/push/push.e2e.test.ts @@ -69,10 +69,13 @@ describe("supabase config push", () => { request.method === "GET" && url.pathname === `/v1/projects/${TEST_PROJECT_REF}` ) { - return new Response("unavailable", { status: 503 }); + return new Response("unavailable", { + status: 503, + headers: { "Retry-After": "0" }, + }); } if (request.method === "GET" && url.pathname.endsWith("/billing/addons")) { - return Response.json({ available_addons: [] }); + return Response.json({ available_addons: [], selected_addons: [] }); } if (request.method === "PATCH" && url.pathname.endsWith("/postgrest")) { return request.text().then((body) => { diff --git a/apps/cli/src/commands/config/push/push.integration.test.ts b/apps/cli/src/commands/config/push/push.integration.test.ts index 29b65beb1e..2286acdd0b 100644 --- a/apps/cli/src/commands/config/push/push.integration.test.ts +++ b/apps/cli/src/commands/config/push/push.integration.test.ts @@ -13,7 +13,6 @@ import { Schema, Stdio, } from "effect"; -import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; @@ -40,6 +39,8 @@ import { } from "../../../../tests/helpers/command-mocks.ts"; import { v2ProjectConfigResponse } from "../../../../tests/helpers/config-fixtures.ts"; import { mockRuntimeInfo, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { YesFlag } from "../../../command-internal/global-flags.ts"; import { resolveWorkdir } from "../../../config/command-settings.layer.ts"; import { cliConfigProviderLayer } from "../../../shared/config/cli-config-provider.layer.ts"; @@ -52,6 +53,7 @@ import { configPushHandler } from "./push.command.ts"; const tempRoot = useTempWorkdir("supabase-config-push-int-"); const REF = VALID_REF; +const API = "https://api.supabase.com"; const workdirPath = Effect.fnUntraced(function* (...segments: ReadonlyArray) { const path = yield* Path.Path; @@ -89,6 +91,7 @@ const workdirFilesLayer = (toml: string, seed: WorkdirSeed = Effect.void) => ).pipe(Layer.provide(BunServices.layer)); const jsonText = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeJsonText = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); const v1TransportFailure = (description: string) => Effect.fail(transportFailure(HttpClientRequest.get(DEFAULT_API_URL), description)); @@ -292,10 +295,14 @@ function setup(opts: { readonly projectId?: Option.Option; /** Extra workdir files (linked-project state) written alongside `config.toml`. */ readonly seed?: WorkdirSeed; + /** Answers the project picker with `REF` and writes the linked-project cache through the real + * cache layer. */ + readonly pickerAndRealCache?: boolean; }) { const out = mockOutput({ format: opts.format ?? "text", promptConfirmResponses: opts.confirm, + ...(opts.pickerAndRealCache === true ? { promptSelectResponses: [REF] } : {}), promptConfirmFail: opts.promptFail, interactive: opts.interactive ?? ((opts.format ?? "text") === "text" && (opts.stdinIsTty ?? true)), @@ -304,7 +311,10 @@ function setup(opts: { handler: (request) => { const url = request.url; if (url.includes("/billing/addons")) { - const a = opts.addons ?? { status: 200, body: { available_addons: [] } }; + const a = opts.addons ?? { + status: 200, + body: { selected_addons: [], available_addons: [] }, + }; return Effect.succeed(jsonResponse(request, a.status, a.body)); } if (url.includes("/v2/projects/")) { @@ -353,7 +363,23 @@ function setup(opts: { const p = opts.authPatch ?? { status: 200, body: authWriteResponseFixture() }; return Effect.succeed(jsonResponse(request, p.status, p.body)); } + if (url.includes("/network-restrictions/apply")) { + return Effect.succeed( + jsonResponse(request, 200, { entitlement: "allowed", config: {}, status: "applied" }), + ); + } + if (url.includes("/ssl-enforcement")) { + return Effect.succeed( + jsonResponse(request, 200, { + currentConfig: { database: true }, + appliedSuccessfully: true, + }), + ); + } const pathname = new URL(url).pathname; + if (pathname === "/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [PUSH_TEST_PROJECT])); + } // Live target-detection probe: a bare project ref defaults to a schema-valid, unnamed // project, so plain-project scenarios keep working without opting in. if (/^\/v1\/projects\/[a-z0-9-]+$/.test(pathname)) { @@ -380,25 +406,28 @@ function setup(opts: { const b = opts.branchById ?? { status: 404, body: {} }; return Effect.succeed(jsonResponse(request, b.status, b.body)); } - // Anything else (network-restrictions/ssl/webhooks) — succeed with an empty body; - // scenarios writing to one of those use `setupService()` instead. + // Anything else (webhooks) — succeed with an empty body. return Effect.succeed(jsonResponse(request, 200, {})); }, }); const telemetry = mockTelemetryStateTracked(); const linkedProjectCache = mockLinkedProjectCacheTracked(); + const cliSettings = mockCommandSettings({ + workdir: opts.workdir ?? tempRoot.current, + explicitWorkdir: opts.explicitWorkdir ?? false, + ...(opts.projectId === undefined ? {} : { projectId: opts.projectId }), + }); const layer = Layer.mergeAll( buildTestRuntime({ out, api, - cliSettings: mockCommandSettings({ - workdir: opts.workdir ?? tempRoot.current, - explicitWorkdir: opts.explicitWorkdir ?? false, - ...(opts.projectId === undefined ? {} : { projectId: opts.projectId }), - }), + cliSettings, runtimeInfo: mockRuntimeInfo({ cwd: opts.runtimeCwd ?? tempRoot.current }), telemetry: telemetry.layer, - linkedProjectCache: linkedProjectCache.layer, + linkedProjectCache: + opts.pickerAndRealCache === true + ? realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }) + : linkedProjectCache.layer, tty: mockTty({ stdinIsTty: opts.stdinIsTty ?? true, stdoutIsTty: @@ -742,7 +771,7 @@ schemas = ["public", "remote_schema"] }); it.effect("fails when listing addons returns 503", () => { - const { layer } = setup({ + const { layer, api } = setup({ toml: `project_id = "test"\n`, yes: true, addons: { status: 503, body: {} }, @@ -750,6 +779,9 @@ schemas = ["public", "remote_schema"] return Effect.gen(function* () { const exit = yield* configPush({ projectRef: Option.none() }).pipe(Effect.exit); expect(Exit.isFailure(exit)).toBe(true); + + expect(api.requests.some((r) => r.url.includes("/v2/projects/"))).toBe(false); + assertPermissionDrift({ command: "config push", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -1262,6 +1294,8 @@ max_rows = 1000 changes: [["api", "schemas"]], }); expect(data["remote_only"]).toBe(1); + + assertPermissionDrift({ command: "config push", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -1298,6 +1332,8 @@ statement_timeout = "8s" ); expect(put).toBeDefined(); expect(put?.body).toEqual({ statement_timeout: "8s" }); + + assertPermissionDrift({ command: "config push", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -1330,6 +1366,8 @@ max_frequency = "10s" sessions_timebox: 24, mfa_phone_max_frequency: 10, }); + + assertPermissionDrift({ command: "config push", requests: api.requests }); }).pipe(Effect.provide(layer)); }, ); @@ -1575,6 +1613,7 @@ otp_expiry = 120 const success = out.messages.find((m) => m.type === "success"); const data = yield* decodeJsonRecord(success?.data); expect(data["remote_only"]).toBe(1); + assertPermissionDrift({ command: "config push", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); }); @@ -1584,25 +1623,6 @@ otp_expiry = 120 // fields (`V1UpdateAuthServiceConfigOutput`) no test should have to hand-author. Storage's write // goes through the real client instead, since its output schema is `Schema.Void`. -function addonsHttpLayer( - body: unknown = { available_addons: [] }, -): Layer.Layer { - return Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => - Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(JSON.stringify(body), { - status: 200, - headers: { "content-type": "application/json" }, - }), - ), - ), - ), - ); -} - const BASE_DISABLED = `project_id = "test"\n`; function setupService(opts: { @@ -1634,6 +1654,11 @@ function setupService(opts: { // gated-service scenario keeps working without opting in; overridable via // `opts.v1.getProject`. getProject: () => Effect.succeed(PUSH_TEST_PROJECT), + listProjectAddons: () => + Effect.succeed({ + selected_addons: [], + available_addons: opts.addons?.available_addons ?? [], + }), ...opts.v1, }, raw: { @@ -1646,7 +1671,7 @@ function setupService(opts: { const layer = Layer.mergeAll( buildTestRuntime({ out, - api: { layer: apiMock.layer, httpClientLayer: addonsHttpLayer(opts.addons) }, + api: { layer: apiMock.layer }, cliSettings: mockCommandSettings({ workdir: opts.workdir ?? tempRoot.current, explicitWorkdir: opts.explicitWorkdir ?? false, @@ -1941,6 +1966,8 @@ secret = "${DOTENVX_ENCRYPTED_VALUE}" ); expect(update).toBeDefined(); expect(update?.body).toEqual({ fileSizeLimit: 104857600 }); + + assertPermissionDrift({ command: "config push", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -1950,35 +1977,102 @@ enabled = true allowed_cidrs = ["1.2.3.4/32"] allowed_cidrs_v6 = ["::1/128"] `; - const { layer, apiMock } = setupService({ - toml, - yes: true, - v1: { updateNetworkRestrictions: () => Effect.succeed({}) }, - }); + const { layer, api } = setup({ toml, yes: true }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - const update = apiMock.requests.find((r) => r.method === "updateNetworkRestrictions"); - expect(update).toBeDefined(); - expect(update?.input).toEqual({ - ref: REF, + const apply = api.requests.find( + (r) => r.method === "POST" && r.url.includes("/network-restrictions/apply"), + ); + expect(apply?.body).toEqual({ dbAllowedCidrs: ["1.2.3.4/32"], dbAllowedCidrsV6: ["::1/128"], }); + assertPermissionDrift({ command: "config push", requests: api.requests }); }).pipe(Effect.provide(layer)); }); it.effect("pushes db.ssl_enforcement only when declared in config", () => { - const toml = `${BASE_DISABLED}[db.ssl_enforcement]\nenabled = true\n`; - const { layer, apiMock } = setupService({ - toml, + const { layer, api } = setup({ + toml: `${BASE_DISABLED}[db.ssl_enforcement]\nenabled = true\n`, yes: true, - v1: { updateSslEnforcementConfig: () => Effect.succeed({}) }, }); return Effect.gen(function* () { yield* configPush({ projectRef: Option.none() }); - const update = apiMock.requests.find((r) => r.method === "updateSslEnforcementConfig"); - expect(update).toBeDefined(); - expect(update?.input).toEqual({ ref: REF, requestedConfig: { database: true } }); + const update = api.requests.find( + (r) => r.method === "PUT" && r.url.includes("/ssl-enforcement"), + ); + expect(update?.body).toEqual({ requestedConfig: { database: true } }); + assertPermissionDrift({ command: "config push", requests: api.requests }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("enables database webhooks when experimental.webhooks is enabled (no diff)", () => { + const { layer, out, api } = setup({ + toml: `${BASE_DISABLED}[experimental.webhooks]\nenabled = true\n`, + yes: true, + }); + return Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + expect(out.stderrText).toContain("Enabling webhooks for project:"); + expect( + api.requests.some( + (r) => + r.method === "POST" && r.url.includes(`/v1/projects/${REF}/database/webhooks/enable`), + ), + ).toBe(true); + assertPermissionDrift({ command: "config push", requests: api.requests }); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "pushes to the project chosen in the picker and fills the real linked-project cache", + () => { + const { layer, out, api } = setup({ + toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`, + yes: true, + projectId: Option.none(), + pickerAndRealCache: true, + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = yield* workdirPath("supabase", ".temp", "linked-project.json"); + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* configPush({ projectRef: Option.none() }); + + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${API}/v1/projects`, + `GET ${API}/v1/projects/${REF}`, + `GET ${API}/v1/projects/${REF}/billing/addons`, + `GET ${API}/v2/projects/${REF}/config`, + `PATCH ${API}/v1/projects/${REF}/postgrest`, + `GET ${API}/v1/projects/${REF}`, + ]); + expect(out.stderrText).toContain(`Pushing config to project: ${REF}`); + expect(yield* decodeJsonText(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: REF, + }); + assertPermissionDrift({ command: "config push", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("a 403 on the target probe still pushes", () => { + const { layer, out, api } = setup({ + toml: `project_id = "test"\n[api]\nmax_rows = 2000\n`, + yes: true, + project: { status: 403, body: { message: "forbidden" } }, + }); + return Effect.gen(function* () { + yield* configPush({ projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${API}/v1/projects/${REF}`, + `GET ${API}/v1/projects/${REF}/billing/addons`, + `GET ${API}/v2/projects/${REF}/config`, + `PATCH ${API}/v1/projects/${REF}/postgrest`, + ]); + expect(out.stderrText).toContain("could not determine whether this is a branch"); + assertPermissionDrift({ command: "config push", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -2002,20 +2096,6 @@ allowed_cidrs_v6 = ["::1/128"] }).pipe(Effect.provide(layer)); }); - it.effect("enables webhooks when experimental.webhooks is enabled (no diff)", () => { - const toml = `${BASE_DISABLED}[experimental.webhooks]\nenabled = true\n`; - const { layer, apiMock, out } = setupService({ - toml, - yes: true, - v1: { enableDatabaseWebhook: () => Effect.succeed({}) }, - }); - return Effect.gen(function* () { - yield* configPush({ projectRef: Option.none() }); - expect(out.stderrText).toContain("Enabling webhooks for project:"); - expect(methodsOf(apiMock)).toContain("enableDatabaseWebhook"); - }).pipe(Effect.provide(layer)); - }); - it.effect("a webhook enable failure fails the push", () => { const toml = `${BASE_DISABLED}[experimental.webhooks]\nenabled = true\n`; const { layer } = setupService({ @@ -3245,7 +3325,7 @@ describe("config push branch/project target detection (CLI-2168)", () => { // `normalizeApiName` folds an empty `name` into `undefined` before it reaches the target // object — the same degradation every other scenario in this file relies on via // `PUSH_TEST_PROJECT`'s default empty name; this test pins it explicitly. - const { layer, out } = setup({ + const { layer, out, api } = setup({ toml: BRANCH_PUSH_TOML, yes: true, project: { status: 200, body: { ...PUSH_TEST_PROJECT, name: "" } }, @@ -3254,6 +3334,8 @@ describe("config push branch/project target detection (CLI-2168)", () => { yield* configPush({ projectRef: Option.none() }); expect(out.stderrText).toContain(`Pushing config to project: ${REF}\n`); expect(out.stderrText).not.toContain("project: ("); + + assertPermissionDrift({ command: "config push", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }, ); @@ -3272,6 +3354,8 @@ describe("config push branch/project target detection (CLI-2168)", () => { expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, ); + + assertPermissionDrift({ command: "config push", requests: api.requests }); }).pipe(Effect.provide(layer)); }, ); @@ -3298,6 +3382,8 @@ describe("config push branch/project target detection (CLI-2168)", () => { expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, ); + + assertPermissionDrift({ command: "config push", requests: api.requests }); }).pipe(Effect.provide(layer)); }, ); @@ -3317,6 +3403,8 @@ describe("config push branch/project target detection (CLI-2168)", () => { expect(out.stderrText).toContain(`Pushing config to branch: ${PROBE_REF}\n`); expect(out.stderrText).not.toContain("Parent project:"); expect(api.requests.some((r) => r.url.includes("/branches"))).toBe(false); + + assertPermissionDrift({ command: "config push", requests: api.requests }); }).pipe(Effect.provide(layer)); }, ); @@ -3499,6 +3587,8 @@ describe("config push branch/project target detection (CLI-2168)", () => { expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, ); + + assertPermissionDrift({ command: "config push", requests: api.requests }); }).pipe(Effect.provide(layer)); }, ); @@ -3553,6 +3643,8 @@ describe("config push branch/project target detection (CLI-2168)", () => { expect(api.requests.some((r) => r.method === "PATCH" && r.url.includes("/postgrest"))).toBe( true, ); + + assertPermissionDrift({ command: "config push", requests: api.requests }); }).pipe(Effect.provide(layer)); }, ); @@ -3605,6 +3697,12 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () (r) => r.method === "GET" && new URL(r.url).pathname === `/v1/projects/${BRANCH_REF}`, ), ).toBe(false); + + assertPermissionDrift({ + command: "config push", + requests: api.requests, + activeFlags: { "project-ref": true }, + }); }).pipe(Effect.provide(layer)); }, ); @@ -3706,6 +3804,12 @@ describe("config push --project-ref branch name/UUID resolution (CLI-2289)", () const branchRequests = api.requests.filter((r) => r.url.includes("/branches")); expect(branchRequests).toHaveLength(1); expect(branchRequests[0]?.url).toContain(`/v1/branches/${BRANCH_UUID}`); + + assertPermissionDrift({ + command: "config push", + requests: api.requests, + activeFlags: { "project-ref": true }, + }); }).pipe(Effect.provide(layer)); }, ); diff --git a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md index b86b6e2ba5..3069cb7117 100644 --- a/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/advisors/SIDE_EFFECTS.md @@ -28,9 +28,9 @@ The local lint query runs inside a transaction that is **always rolled back**. | GET | `/v1/projects/{ref}/advisors/security` | Bearer | — | `{ lints: Lint[] }` | | GET | `/v1/projects/{ref}/advisors/performance` | Bearer | — | `{ lints: Lint[] }` | -Issued via **raw HTTP** (not the typed client) with a tolerant parse, so advisor -`name` / `metadata.type` values the API can add do not fail decoding. `--type` -selects which endpoints run: +Issued through the typed client; the generated schema keeps advisor +`name` / `metadata.type` open, so values the API can add do not fail decoding. +`--type` selects which endpoints run: `security` → security only, `performance` → performance only, `all` → both. ## Database (`--local` / `--db-url`) diff --git a/apps/cli/src/commands/db/advisors/advisors.format.ts b/apps/cli/src/commands/db/advisors/advisors.format.ts index 4d32e96aa3..283195458b 100644 --- a/apps/cli/src/commands/db/advisors/advisors.format.ts +++ b/apps/cli/src/commands/db/advisors/advisors.format.ts @@ -6,8 +6,6 @@ * byte-for-byte. The only `omitempty` field is `metadata`. */ -import { Predicate } from "effect"; - import { encodeHtmlSafeJsonIndented } from "../../../command-internal/html-safe-json.ts"; import { makeLevelEnum } from "../../../command-internal/fail-on.ts"; @@ -44,47 +42,6 @@ const asString = (value: unknown): string => const asStringArray = (value: unknown): ReadonlyArray => Array.isArray(value) ? value.map(asString) : []; -/** - * Decodes a JSON value into a plain string field: an absent or `null` value is the zero value - * `""`; a present non-string (number/bool/object/array) throws. Any string value is accepted - * (an intentional unknown-enum tolerance), used only on the typed-API path, not `rows.Scan`. - */ -function requireApiString(value: unknown, field: string): string { - if (value === undefined || value === null) return ""; - if (typeof value !== "string") { - throw new TypeError(`invalid advisor ${field}: expected a string`); - } - return value; -} - -/** - * Decodes a JSON value into the `categories` string array, matching the - * established output contract: an empty result collapses to `null` rather - * than `[]`, and the key is always present (no `omitempty`). - * - * Mapping: - * - absent / `null` → `null` (encodes as `"categories":null`) - * - present `[]` → `null` (same collapse) - * - present `["SECURITY",…]` → the string array - * - present non-array → throw - * - non-string element → throw - */ -function requireApiStringArray(value: unknown): ReadonlyArray | null { - if (value === undefined || value === null) return null; - if (!Array.isArray(value)) { - throw new TypeError("invalid advisor categories: expected an array of strings"); - } - if (value.length === 0) return null; - return value.map((element) => { - // A null array element decodes to the zero string "". - if (element === null || element === undefined) return ""; - if (typeof element !== "string") { - throw new TypeError("invalid advisor categories element: expected a string"); - } - return element; - }); -} - /** * Normalises a local-query `metadata` (jsonb) cell: the `@effect/sql-pg` driver * returns jsonb already parsed (object), but tolerate a raw JSON string too. @@ -124,117 +81,58 @@ export function scanAdvisorLintRow(row: Record): AdvisorLint { }; } -/** - * The six metadata fields kept, in the established output-contract order. - * - * A JSON `null`/absent `metadata` value is omitted, an object is decoded - * (unknown fields ignored), and any other JSON type — including a - * `fkey_columns` that isn't an array — throws, so a malformed body fails - * rather than silently dropping the metadata. - */ -function projectApiMetadata(value: unknown): Record | undefined { - if (value === undefined || value === null) return undefined; - if (!Predicate.isObject(value)) { - throw new TypeError("invalid advisor metadata: expected an object"); - } - const out: Record = {}; - // Each string subfield decodes absent/null to omitted and a present - // non-string throws. Add in the established output-contract order: entity, - // fkey_columns, fkey_name, name, schema, type. - const optString = (key: string) => { - const field = value[key]; - if (field === undefined || field === null) return; - if (typeof field !== "string") { - throw new TypeError(`invalid advisor metadata.${key}: expected a string`); - } - out[key] = field; +/** The lint shape both advisors endpoints decode to; open enums arrive as plain strings. */ +interface ApiLint { + readonly name: string; + readonly title: string; + readonly level: string; + readonly facing: string; + readonly categories: ReadonlyArray; + readonly description: string; + readonly detail: string; + readonly remediation: string; + readonly metadata?: { + readonly schema?: string; + readonly name?: string; + readonly entity?: string; + readonly type?: string; + readonly fkey_name?: string; + readonly fkey_columns?: ReadonlyArray; }; - - optString("entity"); - const fkeyColumns = value["fkey_columns"]; - if (fkeyColumns !== undefined && fkeyColumns !== null) { - if (!Array.isArray(fkeyColumns)) { - throw new TypeError("invalid advisor metadata.fkey_columns: expected an array of numbers"); - } - const normalized: Array = []; - for (const element of fkeyColumns) { - // A null array element decodes to the zero value (0), not a throw. - if (element === null || element === undefined) { - normalized.push(0); - continue; - } - if (typeof element !== "number") { - throw new TypeError("invalid advisor metadata.fkey_columns element: expected a number"); - } - normalized.push(element); - } - out["fkey_columns"] = normalized; - } - optString("fkey_name"); - optString("name"); - optString("schema"); - optString("type"); - return out; + readonly cache_key: string; } /** - * Reads the advisors API response with plain string narrowing instead of the generated - * closed-enum schema, which would reject advisor names/metadata types the API can add: `name` / - * `level` / `facing` / category values pass through as raw strings. - * - * Structurally strict, though: a top-level non-object, a wrongly-typed `lints`/`categories`/ - * `metadata`/`fkey_columns` container, or a non-object lint entry throws — so a malformed 200 - * body fails instead of being reported as "No issues found". A top-level `null` decodes to the - * zero value (no lints). - * - * @throws On structurally invalid input; the caller maps it to `failed to fetch … advisors`. + * Maps decoded API lints to the output shape: metadata keeps its six fields in the established + * order and an empty `categories` collapses to `null`. */ -export function apiResponseToAdvisorLints(parsed: unknown): ReadonlyArray { - if (parsed === null) return []; - if (!Predicate.isObject(parsed)) { - throw new TypeError("invalid advisors response: expected an object"); - } - const lintsRaw = parsed["lints"]; - if (lintsRaw === undefined || lintsRaw === null) return []; - if (!Array.isArray(lintsRaw)) { - throw new TypeError("invalid advisors lints: expected an array"); - } - const lints: Array = []; - for (const entry of lintsRaw) { - // A null slice element decodes to the zero-value struct, not a throw; normalize - // null/undefined to an empty record so the field decoders produce zero values. - if (entry === null || entry === undefined) { - lints.push({ - name: "", - title: "", - level: "", - facing: "", - categories: null, - description: "", - detail: "", - remediation: "", - cacheKey: "", - }); - continue; - } - if (!Predicate.isObject(entry)) { - throw new TypeError("invalid advisor lint: expected an object"); - } - const metadata = projectApiMetadata(entry["metadata"]); - lints.push({ - name: requireApiString(entry["name"], "name"), - title: requireApiString(entry["title"], "title"), - level: requireApiString(entry["level"], "level"), - facing: requireApiString(entry["facing"], "facing"), - categories: requireApiStringArray(entry["categories"]), - description: requireApiString(entry["description"], "description"), - detail: requireApiString(entry["detail"], "detail"), - remediation: requireApiString(entry["remediation"], "remediation"), +export function apiLintsToAdvisorLints(lints: ReadonlyArray): ReadonlyArray { + return lints.map((lint) => { + const m = lint.metadata; + const metadata = + m === undefined + ? undefined + : { + ...(m.entity !== undefined ? { entity: m.entity } : {}), + ...(m.fkey_columns !== undefined ? { fkey_columns: m.fkey_columns } : {}), + ...(m.fkey_name !== undefined ? { fkey_name: m.fkey_name } : {}), + ...(m.name !== undefined ? { name: m.name } : {}), + ...(m.schema !== undefined ? { schema: m.schema } : {}), + ...(m.type !== undefined ? { type: m.type } : {}), + }; + return { + name: lint.name, + title: lint.title, + level: lint.level, + facing: lint.facing, + categories: lint.categories.length === 0 ? null : lint.categories, + description: lint.description, + detail: lint.detail, + remediation: lint.remediation, ...(metadata !== undefined ? { metadata } : {}), - cacheKey: requireApiString(entry["cache_key"], "cache_key"), - }); - } - return lints; + cacheKey: lint.cache_key, + }; + }); } /** Advisor-type match: `all` matches every lint, otherwise checks categories. */ diff --git a/apps/cli/src/commands/db/advisors/advisors.format.unit.test.ts b/apps/cli/src/commands/db/advisors/advisors.format.unit.test.ts index 302379ae3d..11fe5d243a 100644 --- a/apps/cli/src/commands/db/advisors/advisors.format.unit.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.format.unit.test.ts @@ -1,8 +1,7 @@ -import { Predicate } from "effect"; -import { assert, describe, expect, it } from "vitest"; +import { describe, expect, it } from "vitest"; import { - apiResponseToAdvisorLints, + apiLintsToAdvisorLints, encodeAdvisorLints, filterAdvisorLints, ADVISORS_LEVEL_ENUM, @@ -108,172 +107,39 @@ describe("scanAdvisorLintRow", () => { }); }); -describe("apiResponseToAdvisorLints", () => { - it("coerces API fields to strings and projects metadata to the known fields", () => { - const lints = apiResponseToAdvisorLints({ - lints: [ - { - name: "rls_disabled_in_public", - title: "RLS disabled in public", - level: "ERROR", - facing: "EXTERNAL", - categories: ["SECURITY"], - description: "Tables without RLS", - detail: "Table public.users", - remediation: "https://supabase.com/docs", - cache_key: "test_key", - metadata: { schema: "public", entity: "public.users", type: "table", unknown: "x" }, - }, - ], - }); - expect(lints).toHaveLength(1); - expect(lints[0]?.name).toBe("rls_disabled_in_public"); - expect(lints[0]?.level).toBe("ERROR"); - expect(lints[0]?.categories).toEqual(["SECURITY"]); - const metadata = lints[0]?.metadata; - assert(Predicate.isObject(metadata)); - expect(Object.keys(metadata)).toEqual(["entity", "schema", "type"]); - }); - - it("accepts an unknown advisor name (closed-enum divergence guard)", () => { - const lints = apiResponseToAdvisorLints({ - lints: [{ name: "some_brand_new_advisor", level: "WARN", categories: ["SECURITY"] }], - }); - expect(lints[0]?.name).toBe("some_brand_new_advisor"); - }); - - it("returns an empty array for zero-value shapes (null, missing/null lints)", () => { - expect(apiResponseToAdvisorLints(null)).toEqual([]); - expect(apiResponseToAdvisorLints({})).toEqual([]); - expect(apiResponseToAdvisorLints({ lints: null })).toEqual([]); - }); - - it("null lints element becomes zero-value lint", () => { - const result = apiResponseToAdvisorLints({ - lints: [ - null, - { - name: "rls_disabled", - level: "ERROR", - facing: "EXTERNAL", - categories: ["SECURITY"], - cache_key: "ck", - }, - ], - }); - expect(result).toHaveLength(2); - expect(result[0]).toEqual({ - name: "", - title: "", - level: "", - facing: "", - categories: null, - description: "", - detail: "", - remediation: "", - cacheKey: "", - }); - expect(result[1]?.name).toBe("rls_disabled"); - expect(result[1]?.level).toBe("ERROR"); - }); - - it("null categories element becomes empty string", () => { - const result = apiResponseToAdvisorLints({ - lints: [{ name: "x", categories: [null, "SECURITY"] }], - }); - expect(result[0]?.categories).toEqual(["", "SECURITY"]); - }); - - it("throws on invalid structural shapes", () => { - expect(() => apiResponseToAdvisorLints("nope")).toThrow(); - expect(() => apiResponseToAdvisorLints([])).toThrow(); - expect(() => apiResponseToAdvisorLints({ lints: "nope" })).toThrow(); - expect(() => apiResponseToAdvisorLints({ lints: ["not-an-object"] })).toThrow(); - expect(() => - apiResponseToAdvisorLints({ lints: [{ name: "x", categories: "SECURITY" }] }), - ).toThrow(); - expect(() => apiResponseToAdvisorLints({ lints: [{ name: "x", metadata: "nope" }] })).toThrow(); - expect(() => - apiResponseToAdvisorLints({ - lints: [{ name: "x", metadata: { fkey_columns: "nope" } }], - }), - ).toThrow(); - }); - - it("throws on scalar fields with the wrong JSON type", () => { - expect(() => apiResponseToAdvisorLints({ lints: [{ name: 123 }] })).toThrow(); - expect(() => apiResponseToAdvisorLints({ lints: [{ name: "x", level: true }] })).toThrow(); - expect(() => apiResponseToAdvisorLints({ lints: [{ name: "x", categories: [1] }] })).toThrow(); - expect(() => - apiResponseToAdvisorLints({ lints: [{ name: "x", metadata: { schema: 5 } }] }), - ).toThrow(); - }); - - it("treats absent scalar fields as the empty-string zero value", () => { - // `categories` absent encodes as `null` too (no omitempty on the field). - const lints = apiResponseToAdvisorLints({ lints: [{ name: "only_name" }] }); - expect(lints[0]).toEqual({ - name: "only_name", - title: "", - level: "", - facing: "", - categories: null, - description: "", - detail: "", - remediation: "", - cacheKey: "", - }); - }); - - it("collapses null and empty categories to null", () => { - const fromNull = apiResponseToAdvisorLints({ - lints: [{ name: "x", categories: null }], - }); - expect(fromNull[0]?.categories).toBeNull(); - const fromEmpty = apiResponseToAdvisorLints({ - lints: [{ name: "x", categories: [] }], - }); - expect(fromEmpty[0]?.categories).toBeNull(); - const fromPopulated = apiResponseToAdvisorLints({ - lints: [{ name: "x", categories: ["SECURITY"] }], - }); - expect(fromPopulated[0]?.categories).toEqual(["SECURITY"]); - }); - - it("normalizes null fkey_columns elements to 0", () => { - const result = apiResponseToAdvisorLints({ - lints: [{ name: "x", metadata: { fkey_columns: [null, 2] } }], - }); - expect(result[0]?.metadata).toMatchObject({ fkey_columns: [0, 2] }); - }); - - it("normalizes a fully-null fkey_columns array to all zeros", () => { - const result = apiResponseToAdvisorLints({ - lints: [{ name: "x", metadata: { fkey_columns: [null, null] } }], - }); - expect(result[0]?.metadata).toMatchObject({ fkey_columns: [0, 0] }); - }); - - it("still throws on non-number, non-null fkey_columns elements", () => { - expect(() => - apiResponseToAdvisorLints({ - lints: [{ name: "x", metadata: { fkey_columns: [1, "x"] } }], - }), - ).toThrow("invalid advisor metadata.fkey_columns element: expected a number"); - - expect(() => - apiResponseToAdvisorLints({ - lints: [{ name: "x", metadata: { fkey_columns: [1, true] } }], - }), - ).toThrow(); +describe("apiLintsToAdvisorLints", () => { + const apiLint = { + name: "rls_disabled_in_public", + title: "RLS disabled in public", + level: "ERROR", + facing: "EXTERNAL", + categories: ["SECURITY"], + description: "Tables without RLS", + detail: "Table public.users", + remediation: "https://supabase.com/docs", + cache_key: "test_key", + }; + + it("orders metadata keys by the output contract and omits absent fields", () => { + const [mapped] = apiLintsToAdvisorLints([ + { + ...apiLint, + metadata: { type: "table", schema: "public", fkey_columns: [1, 2], entity: "public.users" }, + }, + ]); + expect(Object.keys(mapped?.metadata as Record)).toEqual([ + "entity", + "fkey_columns", + "schema", + "type", + ]); + expect(mapped?.cacheKey).toBe("test_key"); }); - it("still throws on a non-array fkey_columns", () => { - expect(() => - apiResponseToAdvisorLints({ - lints: [{ name: "x", metadata: { fkey_columns: "nope" } }], - }), - ).toThrow("invalid advisor metadata.fkey_columns: expected an array of numbers"); + it("collapses empty categories to null and omits metadata when absent", () => { + const [mapped] = apiLintsToAdvisorLints([{ ...apiLint, categories: [] }]); + expect(mapped?.categories).toBeNull(); + expect("metadata" in (mapped ?? {})).toBe(false); }); }); diff --git a/apps/cli/src/commands/db/advisors/advisors.handler.ts b/apps/cli/src/commands/db/advisors/advisors.handler.ts index 750938f8d5..fcf226533c 100644 --- a/apps/cli/src/commands/db/advisors/advisors.handler.ts +++ b/apps/cli/src/commands/db/advisors/advisors.handler.ts @@ -9,7 +9,6 @@ import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { aqua } from "../../../command-internal/colors.ts"; import { missingAccessTokenMessage } from "../../../auth/access-token.ts"; import { failsOn } from "../../../command-internal/fail-on.ts"; -import { IdentityStitch } from "../../../command-internal/identity-stitch.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; import { DbConnection } from "../../../command-internal/db-connection.service.ts"; import type { DbSession } from "../../../command-internal/db-connection.service.ts"; @@ -119,9 +118,6 @@ const runLinked = Effect.fn("db.advisors.runLinked")(function* ( const credentials = yield* CommandCredentials; const projectRefResolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; - // Every Management API response is wrapped in identity stitching, including the raw-HTTP - // advisor GETs; one stitcher is shared across both calls so it fires at most once per session. - const { stitch } = yield* IdentityStitch; // The linked-project cache is written whenever the project ref was resolved, even when the // DB-config resolve below fails. Load the ref first (non-prompting, honoring an explicit @@ -163,10 +159,10 @@ const runLinked = Effect.fn("db.advisors.runLinked")(function* ( const lints: Array = []; if (advisorType === "all" || advisorType === "security") { - lints.push(...(yield* fetchSecurityAdvisors(ref, stitch))); + lints.push(...(yield* fetchSecurityAdvisors(ref))); } if (advisorType === "all" || advisorType === "performance") { - lints.push(...(yield* fetchPerformanceAdvisors(ref, stitch))); + lints.push(...(yield* fetchPerformanceAdvisors(ref))); } // The endpoint selection already applied the type filter, so filter by "all". return filterAdvisorLints(lints, "all", level); diff --git a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts index 83181c6cd9..cb0637b9a0 100644 --- a/apps/cli/src/commands/db/advisors/advisors.integration.test.ts +++ b/apps/cli/src/commands/db/advisors/advisors.integration.test.ts @@ -25,6 +25,7 @@ import { import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { asRecordedRequests, + projectResponse, provideEnv, realDbConfigResolver, realLinkedProjectCache, @@ -41,7 +42,6 @@ import { InvalidAccessTokenError } from "../../../auth/errors.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { DbConfigIpv6Error } from "../../../command-internal/db-config.errors.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; -import { IdentityStitch } from "../../../command-internal/identity-stitch.ts"; import type { DbConfigFlags, ResolvedDbConfig } from "../../../command-internal/db-config.types.ts"; import { DbExecError } from "../../../command-internal/db-connection.errors.ts"; import { @@ -69,15 +69,6 @@ const LOCAL_CONN: PgConnInput = { const [SETUP_SQL, QUERY_SQL] = splitLintsSql(); -function jsonParseErrorText(raw: string): string { - try { - JSON.parse(raw); - return ""; - } catch (cause) { - return String(cause); - } -} - /** A local lint row keyed by the column names the `lints.sql` query aliases. */ function lintRow(over: Partial> = {}) { return { @@ -217,24 +208,6 @@ function mockCredentials(opts: { token?: "valid" | "none" | "invalid" } = {}) { return { layer }; } -/** Tracks the raw-HTTP advisor path running the identity stitch. */ -function mockIdentityStitch() { - let calls = 0; - const layer = Layer.succeed(IdentityStitch, { - stitch: () => - Effect.sync(() => { - calls += 1; - }), - stitchedDistinctId: () => undefined, - }); - return { - layer, - get calls() { - return calls; - }, - }; -} - interface SetupOpts { format?: "text" | "json" | "stream-json"; rows?: ReadonlyArray>; @@ -244,8 +217,7 @@ interface SetupOpts { invalidToken?: boolean; ipv6Error?: boolean; securityStatus?: number; - securityNonJson?: boolean; - securityJsonBody?: string; + securityRawBody?: string; securityLints?: ReadonlyArray>; performanceLints?: ReadonlyArray>; /** Raw CLI args for `CliArgs` — drives DB target selection (Changed-based). */ @@ -275,23 +247,11 @@ function setup(opts: SetupOpts = {}) { if (status !== 200) { return Effect.succeed(jsonResponse(request, status, { message: "boom" })); } - if (opts.securityNonJson === true) { - // 200 with a non-JSON content-type (proxy/header regression). + if (opts.securityRawBody !== undefined) { return Effect.succeed( HttpClientResponse.fromWeb( request, - new Response(JSON.stringify({ lints: [] }), { - status: 200, - headers: { "content-type": "text/plain" }, - }), - ), - ); - } - if (opts.securityJsonBody !== undefined) { - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(opts.securityJsonBody, { + new Response(opts.securityRawBody, { status: 200, headers: { "content-type": "application/json" }, }), @@ -304,14 +264,7 @@ function setup(opts: SetupOpts = {}) { return Effect.succeed(jsonResponse(request, 200, { lints: opts.performanceLints ?? [] })); } if (request.method === "GET" && url.endsWith(`/v1/projects/${VALID_REF}`)) { - return Effect.succeed( - jsonResponse(request, 200, { - ref: VALID_REF, - name: "alpha", - organization_id: "org-123", - organization_slug: "acme", - }), - ); + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); } return Effect.succeed(jsonResponse(request, 404, {})); }, @@ -324,7 +277,6 @@ function setup(opts: SetupOpts = {}) { const credentials = mockCredentials({ token: opts.invalidToken === true ? "invalid" : opts.loggedIn === false ? "none" : "valid", }); - const identityStitch = mockIdentityStitch(); const layer = Layer.mergeAll( out.layer, @@ -344,8 +296,7 @@ function setup(opts: SetupOpts = {}) { : realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), cliSettings, credentials.layer, - identityStitch.layer, - api.httpClientLayer, + api.factoryLayer, Layer.succeed(DnsResolverFlag, "native"), Layer.succeed(CliArgs, { args: opts.args ?? [] }), ); @@ -358,7 +309,6 @@ function setup(opts: SetupOpts = {}) { cache, api, projectRef, - identityStitch, resolver, }; } @@ -572,6 +522,9 @@ describe("db advisors — linked", () => { level: "ERROR", facing: "EXTERNAL", categories: ["SECURITY"], + description: "Detects tables without RLS.", + detail: "Table public.users has RLS disabled", + remediation: "https://supabase.com/docs", cache_key: "sec", }; const performanceLint = { @@ -580,6 +533,9 @@ describe("db advisors — linked", () => { level: "INFO", facing: "EXTERNAL", categories: ["PERFORMANCE"], + description: "Detects unindexed foreign keys.", + detail: "Table public.posts has an unindexed foreign key", + remediation: "https://supabase.com/docs", cache_key: "perf", }; @@ -770,20 +726,6 @@ describe("db advisors — linked", () => { }).pipe(Effect.provide(layer)); }); - it.effect("runs the identity stitch on each advisor response", () => { - // Every Management API response is wrapped in identity stitching; the raw-HTTP advisor path - // must run the same stitch, once per response. - const { layer, identityStitch } = setup({ - securityLints: [securityLint], - performanceLints: [performanceLint], - args: ["--linked"], - }); - return Effect.gen(function* () { - yield* dbAdvisors(flags({ level: Option.some("info") })); - expect(identityStitch.calls).toBe(2); - }).pipe(Effect.provide(layer)); - }); - it.effect("resolves the linked ref via the non-prompting load", () => { // `resolve` opens an interactive project picker on a TTY; `--linked` must avoid it. const { layer, projectRef } = setup({ @@ -855,34 +797,66 @@ describe("db advisors — linked", () => { }).pipe(Effect.provide(layer)); }); - it.effect("fails on a 200 with a non-JSON content type (a JSON content type is required)", () => { - // The body is only decoded when Content-Type contains "json"; otherwise this fails as a - // status-200 error. - const { layer } = setup({ securityNonJson: true, args: ["--linked"] }); + it.effect("fails with a status-200 error when the advisors body is not JSON", () => { + const { layer } = setup({ securityRawBody: "gateway", args: ["--linked"] }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbAdvisors(flags({ type: Option.some("security") }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - const causeText = Cause.pretty(exit.cause); - expect(causeText).toContain("unexpected security advisors status 200"); + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "DbAdvisorsSecurityStatusError", + status: 200, + }); } }).pipe(Effect.provide(layer)); }); - it.effect("fails with the JSON parse error when a JSON advisors body is malformed", () => { - const { layer } = setup({ securityJsonBody: "{ not json", args: ["--linked"] }); + it.effect("fails as a decode error when the advisors body has the wrong shape", () => { + const { layer } = setup({ securityRawBody: '{"lints":"nope"}', args: ["--linked"] }); return Effect.gen(function* () { const exit = yield* Effect.exit(dbAdvisors(flags({ type: Option.some("security") }))); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({ - message: `failed to fetch security advisors: ${jsonParseErrorText("{ not json")}`, + _tag: "DbAdvisorsSecurityNetworkError", decode: true, }); } }).pipe(Effect.provide(layer)); }); + it.effect("renders advisors whose name and metadata type the contract does not list", () => { + const { layer, out } = setup({ + format: "json", + securityLints: [ + { + ...securityLint, + name: "public_bucket_allows_listing", + categories: ["SECURITY", "COMPLIANCE"], + metadata: { name: "avatars", type: "bucket", extra: "dropped" }, + }, + ], + args: ["--linked"], + }); + return Effect.gen(function* () { + yield* dbAdvisors(flags({ type: Option.some("security") })); + expect(out.messages).toContainEqual( + expect.objectContaining({ + type: "success", + data: { + results: [ + expect.objectContaining({ + name: "public_bucket_allows_listing", + categories: ["SECURITY", "COMPLIANCE"], + metadata: { name: "avatars", type: "bucket" }, + }), + ], + }, + }), + ); + }).pipe(Effect.provide(layer)); + }); + it.effect("fails when the advisors API returns a non-200 status", () => { const { layer } = setup({ securityStatus: 500, args: ["--linked"] }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/db/advisors/advisors.layers.ts b/apps/cli/src/commands/db/advisors/advisors.layers.ts index 30b0239646..99aae70ac5 100644 --- a/apps/cli/src/commands/db/advisors/advisors.layers.ts +++ b/apps/cli/src/commands/db/advisors/advisors.layers.ts @@ -17,8 +17,8 @@ import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts * Runtime layer for `supabase db advisors`, which spans two backends: * * - **`--local` / `--db-url`** — the Postgres connection + db-config resolver. - * - **`--linked`** — raw-HTTP advisor GETs, project-ref resolution, and the - * linked-project cache. + * - **`--linked`** — typed advisor GETs via the lazy platform-API factory, project-ref + * resolution, and the linked-project cache. * * Does not use `managementApiRuntimeLayer`: that layer eagerly builds `CommandPlatformApi`, * resolving an access token at layer construction, which would make the auth-free `--local` path @@ -74,10 +74,9 @@ export const dbAdvisorsRuntimeLayer = Layer.mergeAll( credentials, projectRef, linkedProjectCache, - // The one per-command identity stitcher, exposed at top level so the raw-HTTP advisor GETs can - // yield it. The same reference is provided to platformApiFactory / linkedProjectCache / - // dbConfig above, so memoisation makes them all share one `stitchAttempted` guard, firing at - // most once. + platformApiFactory, + // Exposed at top level so `withCommandTelemetry` can read `stitchedDistinctId()`. Memoised by + // reference, so it is the same instance the factory's typed client stitches through. identityStitchLayer, telemetryStateLayer, commandRuntimeLayer(["db", "advisors"]), diff --git a/apps/cli/src/commands/db/advisors/advisors.linked.ts b/apps/cli/src/commands/db/advisors/advisors.linked.ts index 22887a43a4..3d3b8025ff 100644 --- a/apps/cli/src/commands/db/advisors/advisors.linked.ts +++ b/apps/cli/src/commands/db/advisors/advisors.linked.ts @@ -1,146 +1,47 @@ import { Effect } from "effect"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientError from "effect/http/HttpClientError"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; -import type * as HttpClientResponse from "effect/http/HttpClientResponse"; -import { CommandSettings } from "../../../config/command-settings.service.ts"; -import { sanitizeErrorBody } from "../../../command-internal/http-errors.ts"; -import { requestWithAuth } from "../../../command-internal/raw-http.ts"; -import { resolveAccessToken } from "../../../command-internal/resolve-token.ts"; +import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; +import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { DbAdvisorsPerformanceNetworkError, DbAdvisorsPerformanceStatusError, DbAdvisorsSecurityNetworkError, DbAdvisorsSecurityStatusError, } from "./advisors.errors.ts"; -import { apiResponseToAdvisorLints } from "./advisors.format.ts"; +import { apiLintsToAdvisorLints } from "./advisors.format.ts"; -interface AdvisorEndpoint { - readonly path: "security" | "performance"; - /** - * Builds the network/parse failure (`failed to fetch … advisors: `). - * `decode: true` marks a 200-response body decode failure rather than a - * transport failure, even though both fold into the same message path. - */ - readonly network: (message: string, opts?: { readonly decode?: boolean }) => AdvisorNetworkError; - /** Builds the non-200 failure (`unexpected … advisors status : `). */ - readonly status: (status: number, body: string) => AdvisorStatusError; -} - -type AdvisorNetworkError = DbAdvisorsSecurityNetworkError | DbAdvisorsPerformanceNetworkError; -type AdvisorStatusError = DbAdvisorsSecurityStatusError | DbAdvisorsPerformanceStatusError; - -const describeHttpError = (cause: unknown): string => - HttpClientError.isHttpClientError(cause) - ? (cause.reason.description ?? cause.reason._tag) - : String(cause); - -const decodeAdvisorsBody = (rawBody: string) => apiResponseToAdvisorLints(JSON.parse(rawBody)); +const mapSecurityError = mapHttpError({ + networkError: DbAdvisorsSecurityNetworkError, + statusError: DbAdvisorsSecurityStatusError, + networkMessage: (cause) => `failed to fetch security advisors: ${cause}`, + statusMessage: (status, body) => `unexpected security advisors status ${status}: ${body}`, +}); -/** Identity stitcher: every Management API response is wrapped in identity - * stitching; the raw-HTTP advisor path runs it explicitly. */ -type StitchFn = (response: HttpClientResponse.HttpClientResponse) => Effect.Effect; +const mapPerformanceError = mapHttpError({ + networkError: DbAdvisorsPerformanceNetworkError, + statusError: DbAdvisorsPerformanceStatusError, + networkMessage: (cause) => `failed to fetch performance advisors: ${cause}`, + statusMessage: (status, body) => `unexpected performance advisors status ${status}: ${body}`, +}); -/** - * Shared GET for an advisors endpoint. Uses raw HTTP + a tolerant parse rather - * than the typed client, since the generated schema's closed `name` / - * `metadata.type` literals would reject advisor names / metadata types the - * API can add. - */ -const fetchAdvisors = Effect.fnUntraced(function* ( +export const fetchSecurityAdvisors = Effect.fn("db.advisors.fetchSecurity")(function* ( ref: string, - endpoint: AdvisorEndpoint, - stitch: StitchFn, ) { - const httpClient = yield* HttpClient.HttpClient; - const cliSettings = yield* CommandSettings; - const tokenOpt = yield* resolveAccessToken; - - const request = requestWithAuth( - HttpClientRequest.get(`${cliSettings.apiUrl}/v1/projects/${ref}/advisors/${endpoint.path}`), - tokenOpt, - cliSettings.userAgent, - ); - - const response = yield* httpClient - .execute(request) - .pipe(Effect.mapError((cause) => endpoint.network(describeHttpError(cause)))); - - // Stitch the session identity from the X-Gotrue-Id header, run on every - // Management API response. - yield* stitch(response); - - if (response.status !== 200) { - const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - return yield* endpoint.status(response.status, sanitizeErrorBody(rawBody)); - } - - // The 200 body is only decoded when the Content-Type header contains "json"; - // otherwise the fetcher returns the status-200 error. Match that so a header - // regression returning JSON text isn't accepted as a valid advisor result. - const contentType = response.headers["content-type"] ?? ""; - if (!contentType.toLowerCase().includes("json")) { - const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - return yield* endpoint.status(200, sanitizeErrorBody(rawBody)); - } - - const rawBody = yield* response.text; - // A decode error folds into the same `failed to fetch … advisors: ` path, - // so map both JSON syntax errors and structural-shape rejections (thrown by - // `apiResponseToAdvisorLints`) to the endpoint's network error. - const lints = yield* Effect.try({ - try: () => decodeAdvisorsBody(rawBody), - catch: (cause) => endpoint.network(String(cause), { decode: true }), - }); + const api = yield* (yield* CommandPlatformApiFactory).make; + const output = yield* api.v1.getSecurityAdvisors({ ref }).pipe(Effect.catch(mapSecurityError)); + const lints = apiLintsToAdvisorLints(output.lints); yield* Effect.annotateCurrentSpan("advisor.count", lints.length); return lints; }); -export const fetchSecurityAdvisors = (ref: string, stitch: StitchFn) => - fetchAdvisors( - ref, - { - path: "security", - network: (message, opts) => - new DbAdvisorsSecurityNetworkError({ - message: `failed to fetch security advisors: ${message}`, - decode: opts?.decode, - }), - status: (status, body) => - new DbAdvisorsSecurityStatusError({ - status, - body, - message: `unexpected security advisors status ${status}: ${body}`, - }), - }, - stitch, - ).pipe( - Effect.withSpan("v1GetSecurityAdvisors", { - attributes: { "api.operation": "v1GetSecurityAdvisors" }, - }), - ); - -export const fetchPerformanceAdvisors = (ref: string, stitch: StitchFn) => - fetchAdvisors( - ref, - { - path: "performance", - network: (message, opts) => - new DbAdvisorsPerformanceNetworkError({ - message: `failed to fetch performance advisors: ${message}`, - decode: opts?.decode, - }), - status: (status, body) => - new DbAdvisorsPerformanceStatusError({ - status, - body, - message: `unexpected performance advisors status ${status}: ${body}`, - }), - }, - stitch, - ).pipe( - Effect.withSpan("v1GetPerformanceAdvisors", { - attributes: { "api.operation": "v1GetPerformanceAdvisors" }, - }), - ); +export const fetchPerformanceAdvisors = Effect.fn("db.advisors.fetchPerformance")(function* ( + ref: string, +) { + const api = yield* (yield* CommandPlatformApiFactory).make; + const output = yield* api.v1 + .getPerformanceAdvisors({ ref }) + .pipe(Effect.catch(mapPerformanceError)); + const lints = apiLintsToAdvisorLints(output.lints); + yield* Effect.annotateCurrentSpan("advisor.count", lints.length); + return lints; +}); diff --git a/apps/cli/src/commands/db/branch/branch.command.ts b/apps/cli/src/commands/db/branch/branch.command.ts index 8d133ba354..cec16d07c0 100644 --- a/apps/cli/src/commands/db/branch/branch.command.ts +++ b/apps/cli/src/commands/db/branch/branch.command.ts @@ -1,4 +1,5 @@ import { Argument, Command } from "effect/cli"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { removedCommand } from "../../../command-internal/removed-command.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; @@ -18,6 +19,8 @@ function removedBranchLeaf(name: string, argDescription: string) { } as const).pipe( Command.withDescription("Removed: local database branches are no longer supported."), Command.withShortDescription("Removed: local database branches are no longer supported"), + // Removed: fails before any Management API call, so no flag changes what it calls. + withPermissions({ operations: [], noApiEffectFlags: [] }), Command.withHandler(() => removedCommand(REMOVED_SUGGESTION).pipe(withCommandTelemetry(), withJsonErrorHandling), ), @@ -33,6 +36,8 @@ const dbBranchSwitchCommand = removedBranchLeaf("switch", "Name of the branch to const dbBranchListCommand = Command.make("list", {}).pipe( Command.withDescription("Removed: local database branches are no longer supported."), Command.withShortDescription("Removed: local database branches are no longer supported"), + // Removed: fails before any Management API call, so no flag changes what it calls. + withPermissions({ operations: [], noApiEffectFlags: [] }), Command.withHandler(() => removedCommand(REMOVED_SUGGESTION).pipe(withCommandTelemetry(), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/db/diff/diff.handler.ts b/apps/cli/src/commands/db/diff/diff.handler.ts index 046ca7a5a5..2daeec55dd 100644 --- a/apps/cli/src/commands/db/diff/diff.handler.ts +++ b/apps/cli/src/commands/db/diff/diff.handler.ts @@ -202,6 +202,9 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { // The first migrations endpoint resolved wins: the engine provisions a single migrations // shadow/catalog, so only refs resolved before that point should influence it. let migrationsToml: DbTomlValues | undefined; + // Each linked resolution mints a temporary login role that invalidates the previous one, + // so the preflight and every `linked` ref share a single connection. + let linkedEndpoint: PgDeltaDatabaseEndpoint | undefined; // The preflight target resolve below validates a changed target flag and is stateful: a // changed `--linked` resolves the project ref and merges `[remotes.]`, so the // explicit `local`/`migrations` refs below see that override. `--local`/`--db-url` load @@ -225,6 +228,12 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { yield* Ref.set(linkedRefForCache, Option.some(preflightRef)); mergedLinkedRef = preflightRef; } + linkedEndpoint = { + kind: "database", + ref: toPostgresURL(preflight.conn), + connection: preflight.conn, + connectOptions: { isLocal: preflight.isLocal, dnsResolver }, + }; } } // Read config once, after the preflight: the `[remotes.]`-merged config @@ -287,6 +296,7 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { } satisfies PgDeltaDatabaseEndpoint; } case "linked": { + if (linkedEndpoint !== undefined) return linkedEndpoint; const resolved = yield* resolver.resolve({ dbUrl: Option.none(), connType: "linked", @@ -300,12 +310,13 @@ export const dbDiff = Effect.fn("db.diff")(function* (flags: DbDiffFlags) { mergedLinkedRef = ref2; cfg = yield* readDbToml(fs, path, cliSettings.workdir, ref2); } - return { + linkedEndpoint = { kind: "database", ref: toPostgresURL(resolved.conn), connection: resolved.conn, connectOptions: { isLocal: resolved.isLocal, dnsResolver }, - } satisfies PgDeltaDatabaseEndpoint; + }; + return linkedEndpoint; } case "migrations": // Preserve resolution order: the migrations shadow/catalog config must reflect diff --git a/apps/cli/src/commands/db/diff/diff.integration.test.ts b/apps/cli/src/commands/db/diff/diff.integration.test.ts index 7c1a9ed199..ab6bfa26fd 100644 --- a/apps/cli/src/commands/db/diff/diff.integration.test.ts +++ b/apps/cli/src/commands/db/diff/diff.integration.test.ts @@ -3058,7 +3058,7 @@ describe("db diff permissions through the real resolver and cache", () => { ); }); - it.effect("mints a login role for each side when both --from and --to are linked", () => { + it.effect("mints one login role shared by both sides when --from and --to are linked", () => { const s = setup(tmp.current, { realLayers: true, diffSql: "create table e ();\n" }); const requests: Array = []; return Effect.gen(function* () { @@ -3070,12 +3070,7 @@ describe("db diff permissions through the real resolver and cache", () => { projectRef: Option.some(VALID_REF), }), ).pipe(Effect.provide(s.layer)); - expect(asLines(requests)).toEqual([ - poolerRequest, - loginRoleRequest, - poolerRequest, - loginRoleRequest, - ]); + expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); assertPermissionDrift({ command: "db diff", activeFlags: { from: "linked", to: "linked", "project-ref": VALID_REF }, @@ -3088,4 +3083,31 @@ describe("db diff permissions through the real resolver and cache", () => { provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), ); }); + + it.effect("mints one login role shared by the --linked preflight and both linked sides", () => { + const s = setup(tmp.current, { realLayers: true, diffSql: "create table e ();\n" }); + const requests: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(requests, serveLinkedDatabase(VALID_REF)); + yield* dbDiff( + flags({ + from: Option.some("linked"), + to: Option.some("linked"), + linked: Option.some(true), + projectRef: Option.some(VALID_REF), + }), + ).pipe(Effect.provide(s.layer)); + expect(asLines(requests)).toEqual([poolerRequest, loginRoleRequest]); + assertPermissionDrift({ + command: "db diff", + activeFlags: { linked: true, from: "linked", to: "linked", "project-ref": VALID_REF }, + requests: [...asRecordedRequests(requests), ...s.projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(BunServices.layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); }); diff --git a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md index afe7c5c7a6..630a1acf2b 100644 --- a/apps/cli/src/commands/db/query/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/db/query/SIDE_EFFECTS.md @@ -25,7 +25,7 @@ the result as a table or JSON. | Method | Path | Auth | Request body | Response | | ------ | ----------------------------------- | ------ | ------------------- | ------------------------------------------------------------------------------------------------------------ | -| POST | `/v1/projects/{ref}/database/query` | Bearer | `{"query":""}` | 201, JSON array of row objects (raw — the typed client voids the body, so the linked path uses raw HTTP). | +| POST | `/v1/projects/{ref}/database/query` | Bearer | `{"query":""}` | 201, JSON array of row objects (read via `executeRaw`, since the typed operation voids the body). | | GET | `/v1/projects/{ref}` | Bearer | — | 200 → linked-project cache write; any other status → no write. Fired after the query on the `--linked` path. | ## Environment Variables diff --git a/apps/cli/src/commands/db/query/query.handler.ts b/apps/cli/src/commands/db/query/query.handler.ts index 03737525f0..b64b2d3276 100644 --- a/apps/cli/src/commands/db/query/query.handler.ts +++ b/apps/cli/src/commands/db/query/query.handler.ts @@ -1,17 +1,8 @@ -import { - DateTime, - Effect, - FileSystem, - Option, - Path, - Predicate, - Redacted, - Ref, - Schema, -} from "effect"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import { operationDefinitions } from "@supabase/api/effect"; +import { DateTime, Effect, FileSystem, Option, Path, Predicate, Ref, Schema } from "effect"; +import * as HttpClientError from "effect/http/HttpClientError"; +import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; import { CommandCredentials } from "../../../auth/command-credentials.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; @@ -19,6 +10,10 @@ import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.serv import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { TelemetryOutputFormat } from "../../../telemetry/telemetry-output-format.service.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; +import { + sanitizeErrorBody, + unexpectedStatusMessage, +} from "../../../command-internal/http-errors.ts"; import { DbConnection, type PgConnInput } from "../../../command-internal/db-connection.service.ts"; import { AgentFlag, DnsResolverFlag, OutputFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; @@ -172,41 +167,38 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { ); }; - const runLinked = ( - sql: string, - format: ResolvedFormat, - agentMode: boolean, - ref: string, - token: Redacted.Redacted, - ) => + const runLinked = (sql: string, format: ResolvedFormat, agentMode: boolean, ref: string) => Effect.gen(function* () { - const cliSettings = yield* CommandSettings; - const httpClient = yield* HttpClient.HttpClient; + const apiFactory = yield* CommandPlatformApiFactory; + const api = yield* apiFactory.make; - const request = HttpClientRequest.post( - `${cliSettings.apiUrl}/v1/projects/${ref}/database/query`, - ).pipe( - HttpClientRequest.setHeader("Authorization", `Bearer ${Redacted.value(token)}`), - HttpClientRequest.setHeader("User-Agent", cliSettings.userAgent), - HttpClientRequest.bodyJsonUnsafe({ query: sql }), - ); - const { status, body } = yield* Effect.gen(function* () { - const response = yield* httpClient.execute(request); - const text = yield* response.text; - return { status: response.status, body: text }; - }).pipe( + // `v1RunAQuery` declares no response body, so `executeRaw` keeps the row JSON text intact + // (column order, number formatting) for rendering. + const response = yield* api + .executeRaw( + operationDefinitions.v1RunAQuery, + { ref, query: sql }, + { retry: { requestTimeoutMs: Number.POSITIVE_INFINITY } }, + ) + .pipe( + Effect.mapError( + (cause) => + new DbQueryExecError({ + message: `failed to execute query: ${cause}`, + transport: HttpClientError.isHttpClientError(cause), + }), + ), + ); + const body = yield* response.text.pipe( Effect.mapError( (cause) => - new DbQueryExecError({ - message: `failed to execute query: ${cause}`, - transport: true, - }), + new DbQueryExecError({ message: `failed to execute query: ${cause}`, transport: true }), ), ); - if (status !== 201) { + if (response.status !== 201) { return yield* new DbQueryUnexpectedStatusError({ - status, - message: `unexpected status ${status}: ${body}`, + status: response.status, + message: unexpectedStatusMessage(response.status, sanitizeErrorBody(body)), }); } @@ -255,7 +247,7 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { // For `--linked`, the access token is checked and the project ref is loaded before SQL is // resolved, so a missing `--file` or a blocking stdin pipe doesn't mask a login/not-linked // error. - let linkedAuth: { readonly token: Redacted.Redacted; readonly ref: string } | undefined; + let linkedRef: string | undefined; if (Option.isSome(flags.linked)) { const credentials = yield* CommandCredentials; const projectRef = yield* ProjectRefResolver; @@ -284,16 +276,16 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { suggestion: "Run supabase login first.", }); } - linkedAuth = { token: tokenOpt.value, ref }; + linkedRef = ref; } // Resolves the `--db-url`/local connection target before reading `--file`/stdin, so a bad // `--db-url` or config error surfaces ahead of a missing-file error or a blocking stdin read. const localTarget = - linkedAuth === undefined + linkedRef === undefined ? yield* resolver.resolve({ dbUrl: flags.dbUrl, - // linkedAuth handles `--linked`, so this branch's target is `--db-url` or local. + // linkedRef handles `--linked`, so this branch's target is `--db-url` or local. connType: Option.isSome(flags.dbUrl) ? "db-url" : "local", dnsResolver, }) @@ -350,14 +342,14 @@ export const dbQuery = Effect.fn("db.query")(function* (flags: DbQueryFlags) { yield* telemetryOutputFormat.set(format); yield* Effect.annotateCurrentSpan({ "db.conn_type": - linkedAuth !== undefined ? "linked" : Option.isSome(flags.dbUrl) ? "db-url" : "local", + linkedRef !== undefined ? "linked" : Option.isSome(flags.dbUrl) ? "db-url" : "local", "query.format": format, "query.agent_mode": agentMode, }); // 3. Linked queries go through the Management API; local/--db-url connect directly. - if (linkedAuth !== undefined) { - return yield* runLinked(sql, format, agentMode, linkedAuth.ref, linkedAuth.token).pipe( + if (linkedRef !== undefined) { + return yield* runLinked(sql, format, agentMode, linkedRef).pipe( Effect.withSpan("db.query.runLinked"), ); } diff --git a/apps/cli/src/commands/db/query/query.integration.test.ts b/apps/cli/src/commands/db/query/query.integration.test.ts index 86d0acc0f1..0dcb795b3e 100644 --- a/apps/cli/src/commands/db/query/query.integration.test.ts +++ b/apps/cli/src/commands/db/query/query.integration.test.ts @@ -13,8 +13,6 @@ import { Schema, Stream, } from "effect"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { vi } from "vitest"; @@ -27,6 +25,7 @@ vi.mock("node:net", (importOriginal) => import { VALID_TOKEN, + mockCommandPlatformApi, mockCommandSettings, mockLinkedProjectCacheTracked, mockTelemetryStateTracked, @@ -190,62 +189,6 @@ function mockStdin(opts: { isTTY?: boolean; piped?: string }) { }); } -function mockHttpClient(opts: { - status?: number; - body?: string; - networkFail?: boolean; - route?: (request: { - method: string; - url: string; - }) => { status: number; body: string } | undefined; -}) { - const requests: Array = []; - const calls: Array<{ method: string; url: string }> = []; - const layer = Layer.succeed( - HttpClient.HttpClient, - HttpClient.make((request) => { - requests.push(request.url); - calls.push({ method: request.method, url: request.url }); - const routed = opts.route?.({ method: request.method, url: request.url }); - if (routed !== undefined) { - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(routed.body, { - status: routed.status, - headers: { "content-type": "application/json" }, - }), - ), - ); - } - return opts.networkFail === true - ? Effect.fail( - new HttpClientError.HttpClientError({ - reason: new HttpClientError.TransportError({ request, description: "ECONNREFUSED" }), - }), - ) - : Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(opts.body ?? "[]", { - status: opts.status ?? 201, - headers: { "content-type": "application/json" }, - }), - ), - ); - }), - ); - return { - layer, - get requests() { - return requests; - }, - get calls() { - return calls; - }, - }; -} - interface SetupOpts { format?: "text" | "json" | "stream-json"; isLocal?: boolean; @@ -278,31 +221,44 @@ function setup(opts: SetupOpts = {}) { const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); const telemetryOutputFormat = mockTelemetryOutputFormat(); - const httpClient = mockHttpClient({ - status: opts.linkedStatus, - body: opts.linkedBody, - networkFail: opts.networkFail, - route: (request) => - opts.realCache === true && - request.method === "GET" && - request.url.endsWith(`/v1/projects/${REF}`) - ? { - status: 200, - body: JSON.stringify({ - ref: REF, - name: "alpha", - organization_id: "org-123", - organization_slug: "acme", - }), - } - : undefined, + const platformApi = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + HttpClientResponse.fromWeb( + request, + opts.realCache === true && + request.method === "GET" && + request.url.endsWith(`/v1/projects/${REF}`) + ? new Response( + JSON.stringify({ + ref: REF, + name: "alpha", + organization_id: "org-123", + organization_slug: "acme", + id: REF, + region: "us-east-1", + created_at: "2026-01-01T00:00:00Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.example.com", + version: "15", + postgres_engine: "15", + release_channel: "ga", + }, + }), + { status: 200, headers: { "content-type": "application/json" } }, + ) + : new Response(opts.linkedBody ?? "[]", { status: opts.linkedStatus ?? 201 }), + ), + ), + network: opts.networkFail === true ? "fail" : undefined, }); const layer = Layer.mergeAll( out.layer, telemetry.layer, opts.realCache === true ? realLinkedProjectCache({ - httpClient: httpClient.layer, + httpClient: platformApi.httpClientLayer, cliSettings: mockCommandSettings({ workdir: opts.workdir ?? "/work/project" }), }) : cache.layer, @@ -342,10 +298,10 @@ function setup(opts: SetupOpts = {}) { deleteAllProjectCredentials: Effect.die("unexpected project-credential sweep in test"), deleteProjectCredential: () => Effect.die("unexpected project-credential delete in test"), }), - httpClient.layer, + platformApi.factoryLayer, BunServices.layer, ); - return { layer, out, telemetry, cache, telemetryOutputFormat, httpClient }; + return { layer, out, telemetry, cache, telemetryOutputFormat, platformApi }; } const flags = (over: Partial = {}): DbQueryFlags => ({ @@ -734,25 +690,31 @@ describe("db query integration", () => { }); it.effect("queries the linked project over HTTP and writes the linked-project cache", () => { - const { layer, out, cache, httpClient } = setup({ + const { layer, out, cache, platformApi } = setup({ linkedStatus: 201, linkedBody: '[{"name":"alice","id":1}]', }); return Effect.gen(function* () { yield* dbQuery(flags({ sql: Option.some("select 1"), linked: Option.some(true) })); expect(out.stdoutText).toContain("│ name │ id │"); + expect(platformApi.requests).toHaveLength(1); + expect(platformApi.requests[0]).toMatchObject({ + method: "POST", + url: `https://api.supabase.com/v1/projects/${REF}/database/query`, + body: { query: "select 1" }, + }); expect(cache.cached).toBe(true); assertPermissionDrift({ command: "db query", activeFlags: { linked: true }, - requests: httpClient.calls, + requests: platformApi.requests, exact: true, }); }).pipe(Effect.provide(layer)); }); - it.live("fills the linked-project cache through the real cache layer", () => { - const { layer, httpClient } = setup({ + it.effect("fills the linked-project cache through the real cache layer", () => { + const { layer, platformApi } = setup({ linkedStatus: 201, linkedBody: '[{"name":"alice","id":1}]', realCache: true, @@ -764,21 +726,21 @@ describe("db query integration", () => { expect(yield* fs.exists(cachePath)).toBe(false); yield* dbQuery(flags({ sql: Option.some("select 1"), linked: Option.some(true) })); expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ ref: REF }); - expect(httpClient.calls.map(({ method, url }) => `${method} ${url}`)).toEqual([ + expect(platformApi.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ `POST https://api.supabase.com/v1/projects/${REF}/database/query`, `GET https://api.supabase.com/v1/projects/${REF}`, ]); assertPermissionDrift({ command: "db query", activeFlags: { linked: true }, - requests: httpClient.calls, + requests: platformApi.requests, exact: true, }); }).pipe(Effect.provide(layer)); }); - it.live("mints a login role and queries through the real DB config resolver and cache", () => { - const { layer, httpClient } = setup({ + it.effect("mints a login role and queries through the real DB config resolver and cache", () => { + const { layer, platformApi } = setup({ linkedStatus: 201, linkedBody: '[{"name":"alice","id":1}]', realCache: true, @@ -818,7 +780,7 @@ describe("db query integration", () => { method, url: `https://api.supabase.com${path}`, })), - ...httpClient.calls, + ...platformApi.requests, ], exact: true, }); @@ -837,7 +799,7 @@ describe("db query integration", () => { it.effect("queries the project given via --project-ref without a linked workdir", () => { const FLAG_REF = "flagflagflagflagflag"; - const { layer, out, cache, httpClient } = setup({ + const { layer, out, cache, platformApi } = setup({ linkedStatus: 201, linkedBody: '[{"name":"alice","id":1}]', unlinked: true, @@ -852,14 +814,16 @@ describe("db query integration", () => { ); expect(out.stdoutText).toContain("│ name │ id │"); expect( - httpClient.requests.some((url) => url.includes(`/v1/projects/${FLAG_REF}/database/query`)), + platformApi.requests.some((req) => + req.url.includes(`/v1/projects/${FLAG_REF}/database/query`), + ), ).toBe(true); expect(cache.cached).toBe(true); expect(cache.cachedRef).toBe(FLAG_REF); assertPermissionDrift({ command: "db query", activeFlags: { linked: true, "project-ref": FLAG_REF }, - requests: httpClient.calls, + requests: platformApi.requests, exact: true, }); }).pipe(Effect.provide(layer)); @@ -867,7 +831,7 @@ describe("db query integration", () => { it.effect("--project-ref overrides an already-linked workdir's project ref", () => { const FLAG_REF = "flagflagflagflagflag"; - const { layer, cache, httpClient } = setup({ + const { layer, cache, platformApi } = setup({ linkedStatus: 201, linkedBody: '[{"name":"alice","id":1}]', }); @@ -880,7 +844,9 @@ describe("db query integration", () => { }), ); expect( - httpClient.requests.some((url) => url.includes(`/v1/projects/${FLAG_REF}/database/query`)), + platformApi.requests.some((req) => + req.url.includes(`/v1/projects/${FLAG_REF}/database/query`), + ), ).toBe(true); expect(cache.cached).toBe(true); expect(cache.cachedRef).toBe(FLAG_REF); @@ -890,7 +856,7 @@ describe("db query integration", () => { it.effect("rejects --project-ref without --linked (query defaults to the local target)", () => { const FLAG_REF = "flagflagflagflagflag"; - const { layer, out, cache, httpClient } = setup({ result: SELECT_RESULT }); + const { layer, out, cache, platformApi } = setup({ result: SELECT_RESULT }); return Effect.gen(function* () { const exit = yield* dbQuery( flags({ sql: Option.some("select 1"), projectRef: Option.some(FLAG_REF) }), @@ -900,7 +866,7 @@ describe("db query integration", () => { "--project-ref only applies when targeting the linked project; use it with --linked (not --local or --db-url)", ); expect(out.stdoutText).toBe(""); - expect(httpClient.requests).toEqual([]); + expect(platformApi.requests).toEqual([]); expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); @@ -955,12 +921,22 @@ describe("db query integration", () => { const exit = yield* dbQuery( flags({ sql: Option.some("bad"), linked: Option.some(true) }), ).pipe(Effect.exit); - expect(failMessage(exit)).toContain("unexpected status 400"); + expect(failMessage(exit)).toBe('unexpected status 400: {"message":"syntax error"}'); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }, ); + it.effect("strips terminal control characters from a linked error body", () => { + const { layer } = setup({ linkedStatus: 400, linkedBody: "bad\u001b[31m query" }); + return Effect.gen(function* () { + const exit = yield* dbQuery( + flags({ sql: Option.some("bad"), linked: Option.some(true) }), + ).pipe(Effect.exit); + expect(failMessage(exit)).toBe("unexpected status 400: bad[31m query"); + }).pipe(Effect.provide(layer)); + }); + it.effect("handles an empty linked result array", () => { const { layer, out } = setup({ linkedStatus: 201, linkedBody: "[]" }); return Effect.gen(function* () { @@ -1025,7 +1001,7 @@ describe("db query integration", () => { const exit = yield* dbQuery( flags({ sql: Option.some("select 1"), linked: Option.some(true) }), ).pipe(Effect.exit); - expect(failMessage(exit)).toContain("failed to execute query"); + expect(failMessage(exit)).toContain("failed to execute query: "); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/db/remote/changes/changes.command.ts b/apps/cli/src/commands/db/remote/changes/changes.command.ts index 82de4cb8b6..5da3d60286 100644 --- a/apps/cli/src/commands/db/remote/changes/changes.command.ts +++ b/apps/cli/src/commands/db/remote/changes/changes.command.ts @@ -1,4 +1,5 @@ import { Command, Flag } from "effect/cli"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { removedCommand } from "../../../../command-internal/removed-command.ts"; import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; import { CliConfigKeys } from "../../../../config/cli-config-keys.ts"; @@ -32,6 +33,11 @@ const config = { export const dbRemoteChangesCommand = Command.make("changes", config).pipe( Command.withDescription("Removed: use `supabase db diff --linked` instead."), Command.withShortDescription("Removed: use `db diff --linked` instead"), + // Removed: fails before any Management API call, so no flag changes what it calls. + withPermissions({ + operations: [], + noApiEffectFlags: ["db-url", "linked", "password", "schema"], + }), Command.withHandler(() => removedCommand("Use `supabase db diff --linked` instead.").pipe( withCommandTelemetry(), diff --git a/apps/cli/src/commands/db/remote/commit/commit.command.ts b/apps/cli/src/commands/db/remote/commit/commit.command.ts index 3befc308ed..7b1594d397 100644 --- a/apps/cli/src/commands/db/remote/commit/commit.command.ts +++ b/apps/cli/src/commands/db/remote/commit/commit.command.ts @@ -1,4 +1,5 @@ import { Command, Flag } from "effect/cli"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { removedCommand } from "../../../../command-internal/removed-command.ts"; import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; import { CliConfigKeys } from "../../../../config/cli-config-keys.ts"; @@ -32,6 +33,11 @@ const config = { export const dbRemoteCommitCommand = Command.make("commit", config).pipe( Command.withDescription("Removed: use `supabase db pull` instead."), Command.withShortDescription("Removed: use `db pull` instead"), + // Removed: fails before any Management API call, so no flag changes what it calls. + withPermissions({ + operations: [], + noApiEffectFlags: ["db-url", "linked", "password", "schema"], + }), Command.withHandler(() => removedCommand("Use `supabase db pull` instead.").pipe( withCommandTelemetry(), diff --git a/apps/cli/src/commands/domains/SIDE_EFFECTS.md b/apps/cli/src/commands/domains/SIDE_EFFECTS.md index 4560aee7ce..91712f6196 100644 --- a/apps/cli/src/commands/domains/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/domains/SIDE_EFFECTS.md @@ -6,36 +6,39 @@ Cloudflare DNS-over-HTTPS CNAME pre-check. ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ----------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | ------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | +| `/supabase/.temp/linked-project.json` | JSON | existence check only — `linkedProjectCache` skips its fill when the file exists | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ----------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | always (post-run), after the ref resolves | -| `~/.supabase/telemetry.json` | JSON | always (post-run), success or failure | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | always (post-run), success or failure | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ----------------------------------------------- | ------------ | --------------------------- | -------------------------------------------------------------------------------------------- | -| `GET` | `https://1.1.1.1/dns-query?name=&type=5` | none | none | `{Answer: [{type, data}]}` — first CNAME answer (`create` only, before the POST) | -| `POST` | `/v1/projects/{ref}/custom-hostname/initialize` | Bearer token | `{custom_hostname: string}` | 201 → `{status, custom_hostname, data: {result: {ssl, custom_origin_server, …}}}` (`create`) | -| `GET` | `/v1/projects/{ref}/custom-hostname` | Bearer token | none | 200 → same response shape (`get`) | -| `POST` | `/v1/projects/{ref}/custom-hostname/reverify` | Bearer token | none | 201 → same response shape (`reverify`) | -| `POST` | `/v1/projects/{ref}/custom-hostname/activate` | Bearer token | none | 201 → same response shape (`activate`) | -| `DELETE` | `/v1/projects/{ref}/custom-hostname` | Bearer token | none | 200 → empty/void body (`delete`) | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ----------------------------------------------- | ------------ | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `https://1.1.1.1/dns-query?name=&type=5` | none | none | `{Answer: [{type, data}]}` — first CNAME answer (`create` only, before the POST) | +| `POST` | `/v1/projects/{ref}/custom-hostname/initialize` | Bearer token | `{custom_hostname: string}` | 201 → `{status, custom_hostname, data: {result: {ssl, custom_origin_server, …}}}` (`create`) | +| `GET` | `/v1/projects/{ref}/custom-hostname` | Bearer token | none | 200 → same response shape (`get`) | +| `POST` | `/v1/projects/{ref}/custom-hostname/reverify` | Bearer token | none | 201 → same response shape (`reverify`) | +| `POST` | `/v1/projects/{ref}/custom-hostname/activate` | Bearer token | none | 201 → same response shape (`activate`) | +| `DELETE` | `/v1/projects/{ref}/custom-hostname` | Bearer token | none | 200 → empty/void body (`delete`) | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## 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 ref fallback when `--project-ref` is unset | no (falls back to linked-project file) | -| `SUPABASE_PROFILE` | built-in profile name or path to a YAML profile | no (defaults to `supabase`; sets API URL + project host) | +| 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 ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the picker on a TTY) | +| `SUPABASE_PROFILE` | built-in profile name or path to a YAML profile | no (defaults to `supabase`; sets API URL + project host) | ## Exit Codes @@ -92,7 +95,7 @@ suppressed on stderr. `delete` ignores `-o`. - `--custom-hostname` is required for `create`. - `create` validates the CNAME via Cloudflare DNS-over-HTTPS (`https://1.1.1.1`, 10s timeout) before initializing; on failure it short-circuits before any POST. -- All subcommands resolve the ref via `--project-ref` → `SUPABASE_PROJECT_ID` env → linked-project file. +- All subcommands resolve the ref via `--project-ref` → `SUPABASE_PROJECT_ID` env → `supabase/.temp/project-ref` → interactive project picker (only when stdin is a TTY and output is interactive). - The project-ref fallback env var is `SUPABASE_PROJECT_ID`. - **Notes:** - `--include-raw-output` is removed on every subcommand, `delete` included. Passing it (any value, including `=false`) fails with `--include-raw-output was removed.` and the suggestion `Use -o json instead.`, before credentials are resolved or any API work runs, so it needs no login. `RemovedSurfaceError` records `cli_command_executed` with `error_fingerprint` ending in `:removed_flag`, and the post-run `telemetry.json` read and write under `SUPABASE_HOME` still happens. The flag stays parsed (hidden) only so misuse reaches this error instead of an unknown-flag parse error; its rejection lives in `domains.removed-flag.ts`. diff --git a/apps/cli/src/commands/domains/activate/activate.command.ts b/apps/cli/src/commands/domains/activate/activate.command.ts index f517d6b288..0a24f73211 100644 --- a/apps/cli/src/commands/domains/activate/activate.command.ts +++ b/apps/cli/src/commands/domains/activate/activate.command.ts @@ -2,6 +2,10 @@ import { Effect, Option } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { rejectIncludeRawOutput } from "../domains.removed-flag.ts"; @@ -38,6 +42,16 @@ export const domainsActivateCommand = Command.make("activate", config).pipe( description: "Activate the custom hostname for a project", }, ]), + withPermissions( + compose( + { + operations: [{ operationId: "v1-activate-custom-hostname", kind: "required" }], + noApiEffectFlags: ["include-raw-output"], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => Option.isSome(flags.includeRawOutput) ? rejectIncludeRawOutput(COMMAND_PATH, flags) diff --git a/apps/cli/src/commands/domains/activate/activate.integration.test.ts b/apps/cli/src/commands/domains/activate/activate.integration.test.ts index a9f8fb6696..34207008da 100644 --- a/apps/cli/src/commands/domains/activate/activate.integration.test.ts +++ b/apps/cli/src/commands/domains/activate/activate.integration.test.ts @@ -1,16 +1,23 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { + VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { domainsActivate } from "./activate.handler.ts"; const HOSTNAME_RESPONSE: typeof V1GetHostnameConfigOutput.Type = { @@ -43,6 +50,8 @@ interface SetupOpts { const tempRoot = useTempWorkdir("supabase-domains-activate-int-"); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); const analytics = mockAnalytics(); @@ -85,6 +94,7 @@ describe("domains activate integration", () => { const exit = yield* Effect.exit(domainsActivate(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); expect(api.requests).toHaveLength(1); + assertPermissionDrift({ command: "domains activate", requests: api.requests }); expect(out.stderrText).toContain("Upgrade your plan:"); expect(out.stderrText).toContain("/org/env-org/billing"); expect(analytics.captured).toEqual([ @@ -107,6 +117,7 @@ describe("domains activate integration", () => { expect(api.requests[0]?.url).toContain("/custom-hostname/activate"); expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cached).toBe(true); + assertPermissionDrift({ command: "domains activate", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -158,4 +169,61 @@ describe("domains activate integration", () => { expect(out.progressEvents).toHaveLength(0); }).pipe(Effect.provide(layer)); }); + + it.effect( + "picks the project, activates the hostname and fills the linked-project cache through the real layers", + () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + const route = `${request.method} ${request.url}`; + if (route === "GET https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (route === `GET https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if ( + route === + `POST https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname/activate` + ) { + return Effect.succeed(jsonResponse(request, 201, HOSTNAME_RESPONSE)); + } + return Effect.succeed(jsonResponse(request, 404, { message: "unexpected request" })); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* domainsActivate(baseFlags); + expect(out.stderrText).toContain( + "Custom hostname setup completed. Project is now accessible at shop.acme.dev.", + ); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `POST https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname/activate`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "domains activate", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/commands/domains/create/create.command.ts b/apps/cli/src/commands/domains/create/create.command.ts index 7b4923a276..f99a369729 100644 --- a/apps/cli/src/commands/domains/create/create.command.ts +++ b/apps/cli/src/commands/domains/create/create.command.ts @@ -2,6 +2,10 @@ import { Effect, Option } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { rejectIncludeRawOutput } from "../domains.removed-flag.ts"; @@ -42,6 +46,16 @@ export const domainsCreateCommand = Command.make("create", config).pipe( description: "Create a custom hostname for a project", }, ]), + withPermissions( + compose( + { + operations: [{ operationId: "v1-update-hostname-config", kind: "required" }], + noApiEffectFlags: ["custom-hostname", "include-raw-output"], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => Option.isSome(flags.includeRawOutput) ? rejectIncludeRawOutput(COMMAND_PATH, flags) diff --git a/apps/cli/src/commands/domains/create/create.integration.test.ts b/apps/cli/src/commands/domains/create/create.integration.test.ts index 7bf83052d5..b12c57f778 100644 --- a/apps/cli/src/commands/domains/create/create.integration.test.ts +++ b/apps/cli/src/commands/domains/create/create.integration.test.ts @@ -1,9 +1,9 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Deferred, Effect, Exit, Fiber, Option } from "effect"; +import { Cause, Deferred, Effect, Exit, FileSystem, Fiber, Option, Schema } from "effect"; import { TestClock } from "effect/testing"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, @@ -16,6 +16,11 @@ import { useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; import { DomainsCnameError } from "../domains.errors.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { domainsCreate } from "./create.handler.ts"; const CUSTOM_HOSTNAME = "shop.acme.dev"; @@ -53,6 +58,8 @@ interface SetupOpts { const tempRoot = useTempWorkdir("supabase-domains-create-int-"); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + function setup(opts: SetupOpts = {}) { const cname = opts.cname ?? "ok"; const out = mockOutput({ format: opts.format ?? "text" }); @@ -120,6 +127,7 @@ describe("domains create integration", () => { expect(postedToInitialize(api)).toBe(true); expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cached).toBe(true); + assertPermissionDrift({ command: "domains create", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -141,6 +149,7 @@ describe("domains create integration", () => { expect(Exit.isFailure(exit)).toBe(true); expect(api.requests).toHaveLength(2); expect(postedToInitialize(api)).toBe(true); + assertPermissionDrift({ command: "domains create", requests: api.requests }); expect(out.stderrText).toContain("Upgrade your plan:"); expect(out.stderrText).toContain("/org/env-org/billing"); expect(analytics.captured).toEqual([ @@ -301,4 +310,65 @@ describe("domains create integration", () => { expect(out.progressEvents).toHaveLength(0); }).pipe(Effect.provide(layer)); }); + + it.effect( + "picks the project, creates the hostname and fills the linked-project cache through the real layers", + () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + const route = `${request.method} ${request.url}`; + if (route === "GET https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (route === `GET https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if (request.url.includes("1.1.1.1")) { + return Effect.succeed( + jsonResponse(request, 200, { Answer: [{ type: 5, data: EXPECTED_CNAME }] }), + ); + } + if ( + route === + `POST https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname/initialize` + ) { + return Effect.succeed(jsonResponse(request, 201, HOSTNAME_RESPONSE)); + } + return Effect.succeed(jsonResponse(request, 404, { message: "unexpected request" })); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* domainsCreate(flags()); + expect(out.stderrText).toContain("Custom hostname configuration complete"); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + "GET https://1.1.1.1/dns-query?name=shop.acme.dev&type=5", + `POST https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname/initialize`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "domains create", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/commands/domains/delete/delete.command.ts b/apps/cli/src/commands/domains/delete/delete.command.ts index f1d3a551a0..761ed1265e 100644 --- a/apps/cli/src/commands/domains/delete/delete.command.ts +++ b/apps/cli/src/commands/domains/delete/delete.command.ts @@ -2,6 +2,10 @@ import { Effect, Option } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { rejectIncludeRawOutput } from "../domains.removed-flag.ts"; @@ -36,6 +40,16 @@ export const domainsDeleteCommand = Command.make("delete", config).pipe( description: "Delete the custom hostname config for a project", }, ]), + withPermissions( + compose( + { + operations: [{ operationId: "v1-Delete hostname config", kind: "required" }], + noApiEffectFlags: ["include-raw-output"], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => Option.isSome(flags.includeRawOutput) ? rejectIncludeRawOutput(COMMAND_PATH, flags) diff --git a/apps/cli/src/commands/domains/delete/delete.integration.test.ts b/apps/cli/src/commands/domains/delete/delete.integration.test.ts index 5fb28fe213..7ae3480612 100644 --- a/apps/cli/src/commands/domains/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/domains/delete/delete.integration.test.ts @@ -1,15 +1,22 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { + VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { domainsDelete } from "./delete.handler.ts"; type OutputFlagValue = "env" | "pretty" | "json" | "toml" | "yaml"; @@ -23,6 +30,8 @@ interface SetupOpts { const tempRoot = useTempWorkdir("supabase-domains-delete-int-"); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); const api = mockCommandPlatformApi({ @@ -55,6 +64,7 @@ describe("domains delete integration", () => { expect(api.requests[0]?.method).toBe("DELETE"); expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cached).toBe(true); + assertPermissionDrift({ command: "domains delete", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -116,4 +126,58 @@ describe("domains delete integration", () => { expect(out.progressEvents).toHaveLength(0); }).pipe(Effect.provide(layer)); }); + + it.effect( + "picks the project, deletes the hostname config and fills the linked-project cache through the real layers", + () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + const route = `${request.method} ${request.url}`; + if (route === "GET https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (route === `GET https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if ( + route === `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname` + ) { + return Effect.succeed(jsonResponse(request, 200, {})); + } + return Effect.succeed(jsonResponse(request, 404, { message: "unexpected request" })); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* domainsDelete(baseFlags); + expect(out.stderrText).toBe("Deleted custom hostname config successfully.\n"); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "domains delete", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/commands/domains/domains.format.ts b/apps/cli/src/commands/domains/domains.format.ts index e94c37c8fd..cf0b6b82c5 100644 --- a/apps/cli/src/commands/domains/domains.format.ts +++ b/apps/cli/src/commands/domains/domains.format.ts @@ -32,7 +32,8 @@ function getHostnameStatus(response: HostnameResponse): HostnameStatus | undefin * Trailing-newline presence differs by branch and is part of the established output. */ export function formatHostnameStatus(response: HostnameResponse): string { - switch (getHostnameStatus(response)) { + const status = getHostnameStatus(response); + switch (status) { case "5_services_reconfigured": return response.custom_hostname ? `Custom hostname setup completed. Project is now accessible at ${response.custom_hostname}.` @@ -81,6 +82,8 @@ ${customHostname} CNAME -> ${customOriginServer}`; return "Custom hostname configuration not started.\n"; case undefined: return ""; + default: + return `${status}\n`; } } diff --git a/apps/cli/src/commands/domains/get/get.command.ts b/apps/cli/src/commands/domains/get/get.command.ts index bbbc1d7ba3..21da294f6a 100644 --- a/apps/cli/src/commands/domains/get/get.command.ts +++ b/apps/cli/src/commands/domains/get/get.command.ts @@ -2,6 +2,10 @@ import { Effect, Option } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { rejectIncludeRawOutput } from "../domains.removed-flag.ts"; @@ -38,6 +42,16 @@ export const domainsGetCommand = Command.make("get", config).pipe( description: "Get the custom hostname config for a project", }, ]), + withPermissions( + compose( + { + operations: [{ operationId: "v1-get-hostname-config", kind: "required" }], + noApiEffectFlags: ["include-raw-output"], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => Option.isSome(flags.includeRawOutput) ? rejectIncludeRawOutput(COMMAND_PATH, flags) diff --git a/apps/cli/src/commands/domains/get/get.integration.test.ts b/apps/cli/src/commands/domains/get/get.integration.test.ts index 4f725b79c9..7e76b16f65 100644 --- a/apps/cli/src/commands/domains/get/get.integration.test.ts +++ b/apps/cli/src/commands/domains/get/get.integration.test.ts @@ -1,16 +1,23 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option, Schema } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { + VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { domainsGet } from "./get.handler.ts"; const HOSTNAME_RESPONSE: typeof V1GetHostnameConfigOutput.Type = { @@ -56,6 +63,8 @@ const JsonEnvelope = Schema.Record(Schema.String, Schema.Unknown); const tempRoot = useTempWorkdir("supabase-domains-get-int-"); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); const analytics = mockAnalytics(); @@ -82,13 +91,17 @@ const baseFlags = { projectRef: Option.none(), includeRawOutput: Option. describe("domains get integration", () => { it.effect("prints the hostname status to stderr in text mode", () => { - const { layer, out, telemetry, linkedProjectCache } = setup(); + const { layer, out, api, telemetry, linkedProjectCache } = setup(); return Effect.gen(function* () { yield* domainsGet(baseFlags); expect(out.stderrText).toContain("Custom hostname configuration not started."); expect(out.stdoutText).toBe(""); expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cached).toBe(true); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname`, + ]); + assertPermissionDrift({ command: "domains get", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -367,6 +380,7 @@ describe("domains get integration", () => { const exit = yield* Effect.exit(domainsGet(baseFlags)); expect(Exit.isFailure(exit)).toBe(true); expect(api.requests).toHaveLength(1); + assertPermissionDrift({ command: "domains get", requests: api.requests }); expect(out.stderrText).toContain("Upgrade your plan:"); expect(out.stderrText).toContain("/org/env-org/billing"); expect(analytics.captured).toEqual([ @@ -413,4 +427,56 @@ describe("domains get integration", () => { } }).pipe(Effect.provide(layer)); }); + + it.effect( + "picks the project, fetches the hostname config and fills the linked-project cache through the real layers", + () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + const route = `${request.method} ${request.url}`; + if (route === "GET https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (route === `GET https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if (route === `GET https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname`) { + return Effect.succeed(jsonResponse(request, 200, HOSTNAME_RESPONSE)); + } + return Effect.succeed(jsonResponse(request, 404, { message: "unexpected request" })); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* domainsGet(baseFlags); + expect(out.stderrText).toContain("Custom hostname configuration not started."); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "domains get", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/commands/domains/reverify/reverify.command.ts b/apps/cli/src/commands/domains/reverify/reverify.command.ts index 0d26352068..bc27b67baa 100644 --- a/apps/cli/src/commands/domains/reverify/reverify.command.ts +++ b/apps/cli/src/commands/domains/reverify/reverify.command.ts @@ -2,6 +2,10 @@ import { Effect, Option } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { rejectIncludeRawOutput } from "../domains.removed-flag.ts"; @@ -36,6 +40,16 @@ export const domainsReverifyCommand = Command.make("reverify", config).pipe( description: "Re-verify the custom hostname for a project", }, ]), + withPermissions( + compose( + { + operations: [{ operationId: "v1-verify-dns-config", kind: "required" }], + noApiEffectFlags: ["include-raw-output"], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => Option.isSome(flags.includeRawOutput) ? rejectIncludeRawOutput(COMMAND_PATH, flags) diff --git a/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts b/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts index 349cbed09b..02deac55ee 100644 --- a/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts +++ b/apps/cli/src/commands/domains/reverify/reverify.integration.test.ts @@ -1,16 +1,23 @@ import { type V1GetHostnameConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { + VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { domainsReverify } from "./reverify.handler.ts"; const HOSTNAME_RESPONSE: typeof V1GetHostnameConfigOutput.Type = { @@ -43,6 +50,8 @@ interface SetupOpts { const tempRoot = useTempWorkdir("supabase-domains-reverify-int-"); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + function setup(opts: SetupOpts = {}) { const out = mockOutput({ format: opts.format ?? "text" }); const analytics = mockAnalytics(); @@ -105,6 +114,7 @@ describe("domains reverify integration", () => { expect(api.requests[0]?.url).toContain("/custom-hostname/reverify"); expect(telemetry.flushed).toBe(true); expect(linkedProjectCache.cached).toBe(true); + assertPermissionDrift({ command: "domains reverify", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -157,4 +167,59 @@ describe("domains reverify integration", () => { expect(out.progressEvents).toHaveLength(0); }).pipe(Effect.provide(layer)); }); + + it.effect( + "picks the project, reverifies the hostname and fills the linked-project cache through the real layers", + () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + const route = `${request.method} ${request.url}`; + if (route === "GET https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (route === `GET https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if ( + route === + `POST https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname/reverify` + ) { + return Effect.succeed(jsonResponse(request, 201, HOSTNAME_RESPONSE)); + } + return Effect.succeed(jsonResponse(request, 404, { message: "unexpected request" })); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* domainsReverify(baseFlags); + expect(out.stderrText).toContain("being initialized"); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `POST https://api.supabase.com/v1/projects/${VALID_REF}/custom-hostname/reverify`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "domains reverify", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); }); diff --git a/apps/cli/src/commands/encryption/SIDE_EFFECTS.md b/apps/cli/src/commands/encryption/SIDE_EFFECTS.md index 49a0bad373..ab51a684ab 100644 --- a/apps/cli/src/commands/encryption/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/encryption/SIDE_EFFECTS.md @@ -6,35 +6,38 @@ 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//linked-project.json` | JSON | when `--project-ref` / `SUPABASE_PROJECT_ID` unset, to resolve linked ref | -| stdin | raw bytes / masked TTY | `update-root-key` only — masked TTY input or piped bytes (the key) | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | +| stdin | raw bytes / masked TTY | `update-root-key` only — masked TTY input or piped bytes (the key) | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ---------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | post-run, after the project ref resolves | -| `~/.supabase/telemetry.json` | JSON | post-run, on success or failure | +| Path | Format | When | +| ---------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the project ref resolves (and, for `update-root-key`, after the key is read), on success or failure of the API call — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | post-run, 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}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/pgsodium` | Bearer token | none | `{root_key}` | +| `PUT` | `/v1/projects/{ref}/pgsodium` | Bearer token | `{root_key}` | `{root_key}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | `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 ref (fallback when `--project-ref` unset) | no (falls back to linked-project file → prompt) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| 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 ref (fallback when `--project-ref` unset) | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | ## Exit Codes diff --git a/apps/cli/src/commands/encryption/get-root-key/get-root-key.command.ts b/apps/cli/src/commands/encryption/get-root-key/get-root-key.command.ts index 43c739540f..1456321a96 100644 --- a/apps/cli/src/commands/encryption/get-root-key/get-root-key.command.ts +++ b/apps/cli/src/commands/encryption/get-root-key/get-root-key.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -18,6 +22,15 @@ export type EncryptionGetRootKeyFlags = CliCommand.Command.Config.Infer encryptionGetRootKey(flags).pipe( // `--project-ref` is not telemetry-safe for encryption (no `markFlagTelemetrySafe`). diff --git a/apps/cli/src/commands/encryption/get-root-key/get-root-key.integration.test.ts b/apps/cli/src/commands/encryption/get-root-key/get-root-key.integration.test.ts index 41b44545d5..2d5c8731d4 100644 --- a/apps/cli/src/commands/encryption/get-root-key/get-root-key.integration.test.ts +++ b/apps/cli/src/commands/encryption/get-root-key/get-root-key.integration.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, + jsonResponse, VALID_REF, mockCommandSettings, mockLinkedProjectCacheTracked, @@ -11,8 +12,15 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { encryptionGetRootKey } from "./get-root-key.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const ROOT_KEY_RESPONSE = { root_key: "abc123rootkey" }; interface SetupOpts { @@ -83,6 +91,61 @@ describe("encryption get-root-key integration", () => { return Effect.gen(function* () { yield* encryptionGetRootKey({ projectRef: Option.some(flagRef) }); expect(api.requests[0]?.url).toContain(`/v1/projects/${flagRef}/pgsodium`); + assertPermissionDrift({ + command: "encryption get-root-key", + activeFlags: { "project-ref": true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists projects for the picker and fills the real linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + const path = new URL(request.url).pathname; + if (path === "/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (path === `/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if (path === `/v1/projects/${VALID_REF}/pgsodium`) { + return Effect.succeed(jsonResponse(request, 200, ROOT_KEY_RESPONSE)); + } + return Effect.succeed(jsonResponse(request, 404, { message: "unexpected request" })); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* encryptionGetRootKey(baseFlags); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/pgsodium`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toBe("abc123rootkey\n"); + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "encryption get-root-key", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/encryption/update-root-key/update-root-key.command.ts b/apps/cli/src/commands/encryption/update-root-key/update-root-key.command.ts index 38e38f8556..48771f6ea9 100644 --- a/apps/cli/src/commands/encryption/update-root-key/update-root-key.command.ts +++ b/apps/cli/src/commands/encryption/update-root-key/update-root-key.command.ts @@ -2,6 +2,10 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { ttyLayer } from "../../../shared/runtime/tty.layer.ts"; @@ -29,6 +33,15 @@ const updateRuntime = Layer.mergeAll( export const encryptionUpdateRootKeyCommand = Command.make("update-root-key", config).pipe( Command.withDescription("Update root encryption key of a Supabase project"), Command.withShortDescription("Update the root encryption key"), + withPermissions( + compose( + { + operations: [{ operationId: "v1-update-pgsodium-config", kind: "required" }], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => encryptionUpdateRootKey(flags).pipe( // `--project-ref` is not telemetry-safe for encryption (no `markFlagTelemetrySafe`). diff --git a/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts b/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts index 5d3dbeb3ca..3b2cc9b5fc 100644 --- a/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts +++ b/apps/cli/src/commands/encryption/update-root-key/update-root-key.integration.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema } from "effect"; -import { mockOutput, mockStdin } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockStdin, mockTty } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, + jsonResponse, VALID_REF, mockCommandSettings, mockLinkedProjectCacheTracked, @@ -11,8 +12,15 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { encryptionUpdateRootKey } from "./update-root-key.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const ROOT_KEY_RESPONSE = { root_key: "new-key" }; interface SetupOpts { @@ -71,6 +79,62 @@ describe("encryption update-root-key integration", () => { expect(out.stderrText).toContain("Finished "); expect(out.stderrText).toContain("supabase root-key update"); expect(out.stdoutText).toBe("\n"); + assertPermissionDrift({ + command: "encryption update-root-key", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists projects for the picker and fills the real linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + const path = new URL(request.url).pathname; + if (path === "/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (path === `/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if (path === `/v1/projects/${VALID_REF}/pgsodium`) { + return Effect.succeed(jsonResponse(request, 200, ROOT_KEY_RESPONSE)); + } + return Effect.succeed(jsonResponse(request, 404, { message: "unexpected request" })); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + stdin: mockStdin(false, "new-key"), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* encryptionUpdateRootKey(baseFlags); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `PUT https://api.supabase.com/v1/projects/${VALID_REF}/pgsodium`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(api.requests[1]?.body).toEqual({ root_key: "new-key" }); + expect(out.stderrText).toContain("Finished "); + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "encryption update-root-key", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md index 97d3b2f2bb..86c05a664b 100644 --- a/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/delete/SIDE_EFFECTS.md @@ -28,10 +28,10 @@ and the command handler does not run. See the [Compute command guide](../../../. ## Files Written -| Path | Format | When | -| ----------------------------------------------- | ------ | --------------------------------------------------------------- | -| `/telemetry.json` | JSON | always — flushed on success and on failure | -| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | post-run, once the project ref has resolved (success or failure), only when no such file exists, `/supabase/.temp/project-ref` doesn't name another ref, an access token exists and `GET /v1/projects/{ref}` returns 200 | The compute's directory and its `[compute.]` entry are deliberately left on disk; only the remote compute is deleted. @@ -46,11 +46,12 @@ rather than deleting unasked. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ----------------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none | `instances.live` when present, else `spec.instances` (for the confirmation). A `403` is tolerated: the compute is treated as unknown and the `DELETE` still runs, since the two endpoints are granted separately (`edge_functions:read` vs `edge_functions:write`) | -| `DELETE` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none | status only | -| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ----------------------------------- | ------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none | `instances.live` when present, else `spec.instances` (for the confirmation). A `403` is tolerated: the compute is treated as unknown and the `DELETE` still runs, since the two endpoints are granted separately (`workers_read` vs `workers_write`) | +| `DELETE` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none | status only. Not sent when the `GET` shows nothing deployed under that name | +| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | ## Exit Codes diff --git a/apps/cli/src/commands/experimental/compute/delete/delete.command.ts b/apps/cli/src/commands/experimental/compute/delete/delete.command.ts index ad01d8b174..5086615a71 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.command.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.command.ts @@ -1,5 +1,10 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withKind } from "../../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../../telemetry/linked-project-cache.layer.ts"; +import { computeRead } from "../../../../shared/compute/compute-api.ts"; import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; @@ -35,6 +40,14 @@ export const computeDeleteCommand = Command.make("delete", config).pipe( description: "Skip the confirmation prompt (scripts and CI)", }, ]), + withPermissions( + compose( + withKind(computeRead, "best-effort"), + { operations: [{ operationId: "v2-delete-a-compute-instance", kind: "required" }] }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => computeDelete(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts b/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts index 02d4e86192..c73878224e 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.handler.ts @@ -69,8 +69,8 @@ export const computeDelete = Effect.fn("compute.delete")(function* (flags: Compu const fetching = yield* output.task("Fetching compute..."); // The lookup supplies the instance tally the confirmation quotes and the // "already gone" verdict. A *refusal* is not a prerequisite: the API grants - // the read and the delete separately — `edge_functions:read` for `GET`, - // `edge_functions:write` for `DELETE` — so a credential holding only the + // the read and the delete separately — `workers_read` for `GET`, + // `workers_write` for `DELETE` — so a credential holding only the // latter would not be able to delete a compute it is entitled to delete. A // refused read leaves the compute unknown and the delete goes ahead. A read // that cannot reach compute at all — unserved route, unenrolled project, no diff --git a/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts b/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts index 7f6059c62c..56305b7aeb 100644 --- a/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/delete/delete.integration.test.ts @@ -8,6 +8,8 @@ import { computeRoute, COMPUTE_PROJECT_REF, } from "../../../../../tests/helpers/compute.ts"; +import { assertPermissionDrift } from "../../../../../tests/helpers/permission-drift.ts"; +import { projectResponse } from "../../../../../tests/helpers/permission-real-layers.ts"; import { ComputeDeleteConfirmationRequiredError, ComputeDeleteNotConfirmedError, @@ -64,6 +66,12 @@ describe("compute delete", () => { }); expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + assertPermissionDrift({ + command: "compute delete", + variant: { computeEnabled: true }, + requests: http.driftRequests, + exact: true, + }); expect(out.stdoutText).toContain("permanently deletes"); // Labelled "declared" because this response carries no live tally. // `spec.instances` is the target, not what is running. @@ -83,6 +91,47 @@ describe("compute delete", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.effect("picks the project, deletes the compute and records the linked project", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, out, http } = setupCompute({ + workdir: repo.dir, + realLayers: true, + interactive: true, + promptSelectResponses: [COMPUTE_PROJECT_REF], + promptTextResponses: ["api"], + routes: { + ...routes, + "GET /v1/projects": { status: 200, body: [projectResponse(COMPUTE_PROJECT_REF)] }, + [`GET /v1/projects/${COMPUTE_PROJECT_REF}`]: { + status: 200, + body: projectResponse(COMPUTE_PROJECT_REF), + }, + }, + }); + + return yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* computeDelete({ name: "api", projectRef: Option.none() }); + + expect(http.routeKeys).toEqual([ + "GET /v1/projects", + getRoute, + deleteRoute, + `GET /v1/projects/${COMPUTE_PROJECT_REF}`, + ]); + expect(out.promptSelectCalls).toHaveLength(1); + expect(out.stdoutText).toContain("Deleted Compute"); + expect(yield* fs.exists(`${repo.dir}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "compute delete", + variant: { computeEnabled: true }, + requests: http.driftRequests, + }); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + // The refusal has to precede the DELETE. At emit time `--yes -o env` would // remove the compute and then exit non-zero with no payload, which a script // reads as "the delete failed" and may retry. @@ -112,7 +161,7 @@ describe("compute delete", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); - // The API grants `edge_functions:read` for the GET and `edge_functions:write` + // The API grants `workers_read` for the GET and `workers_write` // for the DELETE separately, so a credential holding only write could not // delete a compute it is entitled to delete. it.effect("deletes with --yes when the credential may not read the compute", () => @@ -131,6 +180,11 @@ describe("compute delete", () => { yield* computeDelete({ name: "api", projectRef: Option.none() }); expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + assertPermissionDrift({ + command: "compute delete", + variant: { computeEnabled: true }, + requests: http.driftRequests, + }); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -162,7 +216,7 @@ describe("compute delete", () => { it.effect("reports an unreadable compute as deleted, not as nothing to delete", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, out } = setupCompute({ + const { layer, out, http } = setupCompute({ workdir: repo.dir, yes: true, routes: { @@ -176,6 +230,12 @@ describe("compute delete", () => { expect(out.stdoutText).toContain("Deleted Compute"); expect(out.stdoutText).not.toContain("nothing to delete"); + expect(http.routeKeys).toEqual([getRoute, deleteRoute]); + assertPermissionDrift({ + command: "compute delete", + variant: { computeEnabled: true }, + requests: http.driftRequests, + }); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); diff --git a/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md index 2f6ed8be56..821f32e52d 100644 --- a/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/list/SIDE_EFFECTS.md @@ -28,17 +28,18 @@ and the command handler does not run. See the [Compute command guide](../../../. ## Files Written -| Path | Format | When | -| ----------------------------------------------- | ------ | --------------------------------------------------------------- | -| `/telemetry.json` | JSON | always — flushed on success and on failure | -| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | post-run, once the project ref has resolved (success or failure), only when no such file exists, `/supabase/.temp/project-ref` doesn't name another ref, an access token exists and `GET /v1/projects/{ref}` returns 200 | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/v2/projects/{ref}/compute` | Bearer token | none | `data[].id`, `data[].attributes.spec/build_state/deleting` | -| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------- | ------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v2/projects/{ref}/compute` | Bearer token | none | `data[].id`, `data[].attributes.spec/build_state/deleting` | +| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | ## Exit Codes diff --git a/apps/cli/src/commands/experimental/compute/list/list.command.ts b/apps/cli/src/commands/experimental/compute/list/list.command.ts index 0917496af3..1fb9011bad 100644 --- a/apps/cli/src/commands/experimental/compute/list/list.command.ts +++ b/apps/cli/src/commands/experimental/compute/list/list.command.ts @@ -1,5 +1,9 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../../telemetry/linked-project-cache.layer.ts"; import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; @@ -27,6 +31,13 @@ export const computeListCommand = Command.make("list", config).pipe( description: "See every compute in the linked project", }, ]), + withPermissions( + compose( + { operations: [{ operationId: "v2-list-all-compute-instances", kind: "required" }] }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => computeList(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts b/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts index 597124f731..39ffa9a863 100644 --- a/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/list/list.integration.test.ts @@ -8,6 +8,8 @@ import { computeRoute, COMPUTE_PROJECT_REF, } from "../../../../../tests/helpers/compute.ts"; +import { assertPermissionDrift } from "../../../../../tests/helpers/permission-drift.ts"; +import { projectResponse } from "../../../../../tests/helpers/permission-real-layers.ts"; import { resolveWorkdir } from "../../../../config/command-settings.layer.ts"; import { ProjectRefNotLinkedError } from "../../../../config/project-ref.errors.ts"; import { ComputeEnvNotSupportedError } from "../compute.errors.ts"; @@ -39,7 +41,7 @@ describe("compute list", () => { it.effect("shows configured and deployed compute as one inventory", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, out } = setupCompute({ + const { layer, out, http } = setupCompute({ workdir: repo.dir, routes: { [listRoute]: { @@ -76,6 +78,56 @@ describe("compute list", () => { expect(stdout).not.toContain("https://"); expect(rows[1]).toContain("sandbox"); expect(rows[2]).toContain("not deployed"); + expect(http.routeKeys).toEqual([listRoute]); + assertPermissionDrift({ + command: "compute list", + variant: { computeEnabled: true }, + requests: http.driftRequests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect("picks the project, lists its compute and records the linked project", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, out, http } = setupCompute({ + workdir: repo.dir, + realLayers: true, + interactive: true, + promptSelectResponses: [COMPUTE_PROJECT_REF], + routes: { + "GET /v1/projects": { status: 200, body: [projectResponse(COMPUTE_PROJECT_REF)] }, + [`GET /v1/projects/${COMPUTE_PROJECT_REF}`]: { + status: 200, + body: projectResponse(COMPUTE_PROJECT_REF), + }, + [listRoute]: { + status: 200, + body: { data: [computeResource({ name: "api", runtime: "node" })] }, + }, + }, + }); + + return yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* computeList({ projectRef: Option.none() }); + + expect(http.routeKeys).toEqual([ + "GET /v1/projects", + listRoute, + `GET /v1/projects/${COMPUTE_PROJECT_REF}`, + ]); + expect(out.promptSelectCalls).toHaveLength(1); + expect(out.stdoutText).toContain("api"); + expect(yield* fs.exists(`${repo.dir}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "compute list", + variant: { computeEnabled: true }, + requests: http.driftRequests, + exact: true, + }); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -440,7 +492,7 @@ describe("compute list", () => { it.effect("surfaces an unexpected status rather than showing an empty list", () => Effect.gen(function* () { const repo = yield* project(); - const { layer } = setupCompute({ + const { layer, http } = setupCompute({ workdir: repo.dir, routes: { [listRoute]: { status: 500, body: { message: "boom" } } }, }); @@ -449,6 +501,12 @@ describe("compute list", () => { const error = yield* computeList({ projectRef: Option.none() }).pipe(Effect.flip); expect(error).toBeInstanceOf(ComputeApiUnexpectedStatusError); + expect(http.routeKeys).toEqual([listRoute]); + assertPermissionDrift({ + command: "compute list", + variant: { computeEnabled: true }, + requests: http.driftRequests, + }); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -468,6 +526,13 @@ describe("compute list", () => { yield* computeList({ projectRef: Option.some("qrstuvwxyzabcdefghij") }); expect(http.routeKeys).toEqual(["GET /v2/projects/qrstuvwxyzabcdefghij/compute"]); + assertPermissionDrift({ + command: "compute list", + variant: { computeEnabled: true }, + activeFlags: { "project-ref": "qrstuvwxyzabcdefghij" }, + requests: http.driftRequests, + exact: true, + }); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); diff --git a/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md index 42010112ba..707723933b 100644 --- a/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/logs/SIDE_EFFECTS.md @@ -30,18 +30,19 @@ command's output depends on local state — there is no source path to report ## Files Written -| Path | Format | When | -| ----------------------------------------------- | ------ | --------------------------------------------------------------- | -| `/telemetry.json` | JSON | always — flushed on success and on failure | -| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | post-run, once the project ref has resolved (success or failure), only when no such file exists, `/supabase/.temp/project-ref` doesn't name another ref, an access token exists and `GET /v1/projects/{ref}` returns 200 | ## API Routes -| Method | Path | Auth | Request | Response (used fields) | -| ------ | --------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------- | ---------------------- | -| `GET` | `/v1/projects/{ref}/analytics/endpoints/logs` | Bearer token | `sql`, `iso_timestamp_start`, `iso_timestamp_end` as query parameters | `result[]`, `error` | -| `GET` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none — **only when the log query returned no rows**, to tell "not deployed" from "deployed and quiet" | presence only | -| `GET` | `/v1/projects` | Bearer token | none — only when no ref resolved and the session is interactive | project picker | +| Method | Path | Auth | Request | Response (used fields) | +| ------ | --------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/analytics/endpoints/logs` | Bearer token | `sql`, `iso_timestamp_start`, `iso_timestamp_end` as query parameters | `result[]`, `error` | +| `GET` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none — **only when there are no log rows** (the query returned none, or `--tail 0 --follow` ran no query), to tell "not deployed" from "deployed and quiet" | presence only | +| `GET` | `/v1/projects` | Bearer token | none — only when no ref resolved and the session is interactive | project picker | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | Requires the `analytics_logs_read` permission, and the project must be on the Compute private-alpha allow-list — an unenrolled project answers 404. diff --git a/apps/cli/src/commands/experimental/compute/logs/logs.command.ts b/apps/cli/src/commands/experimental/compute/logs/logs.command.ts index a4d2633215..7221e4701c 100644 --- a/apps/cli/src/commands/experimental/compute/logs/logs.command.ts +++ b/apps/cli/src/commands/experimental/compute/logs/logs.command.ts @@ -1,5 +1,10 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withContext } from "../../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../../telemetry/linked-project-cache.layer.ts"; +import { computeRead } from "../../../../shared/compute/compute-api.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../../command-internal/management-api-runtime.layer.ts"; import { @@ -85,6 +90,28 @@ export const computeLogsCommand = Command.make("logs", config).pipe( description: "Skip the backlog and print only lines that arrive from now on", }, ]), + withPermissions( + compose( + { + operations: [ + { + operationId: "v1-get-project-logs", + kind: "required", + context: "--tail isn't 0", + }, + { + operationId: "v1-get-project-logs", + kind: "required", + when: [{ flag: "follow", is: true }], + }, + ], + noApiEffectFlags: ["kind", "tail"], + }, + withContext(computeRead, "the log query returned no rows, or --tail 0 with --follow"), + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => computeLogs(flags).pipe( // `config` as well as `flags`: `--kind` is a choice flag, and the wrapper diff --git a/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts b/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts index db32849c76..c5558f1685 100644 --- a/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/logs/logs.integration.test.ts @@ -4,6 +4,7 @@ import { Cause, Effect, Exit, + FileSystem, Layer, Option, Schedule, @@ -32,6 +33,8 @@ import { mockCommandSettings, VALID_TOKEN, } from "../../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../../tests/helpers/permission-drift.ts"; +import { projectResponse } from "../../../../../tests/helpers/permission-real-layers.ts"; import { mockContextualAnalytics, mockRuntimeInfo, @@ -161,7 +164,7 @@ describe("compute logs", () => { it.effect("prints a compute's own output oldest first", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, out } = setupCompute({ + const { layer, out, http } = setupCompute({ workdir: repo.dir, routes: { [LOGS_ROUTE]: logsResponse([ @@ -182,6 +185,53 @@ describe("compute logs", () => { .split("\n") .map((line) => line.replace(/^\S+\s+\[\S+\]\s+/u, "")); expect(messages).toEqual(["listening on :8080", "terminate hook", "app drained"]); + expect(http.routeKeys).toEqual([LOGS_ROUTE]); + assertPermissionDrift({ + command: "compute logs", + variant: { computeEnabled: true }, + activeFlags: { follow: false }, + requests: http.driftRequests, + }); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect("picks the project, reads its logs and records the linked project", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, out, http } = setupCompute({ + workdir: repo.dir, + realLayers: true, + interactive: true, + promptSelectResponses: [COMPUTE_PROJECT_REF], + routes: { + "GET /v1/projects": { status: 200, body: [projectResponse(COMPUTE_PROJECT_REF)] }, + [`GET /v1/projects/${COMPUTE_PROJECT_REF}`]: { + status: 200, + body: projectResponse(COMPUTE_PROJECT_REF), + }, + [LOGS_ROUTE]: logsResponse([computeLogRow({ message: "listening on :8080" })]), + }, + }); + + return yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* computeLogs(flags()); + + expect(http.routeKeys).toEqual([ + "GET /v1/projects", + LOGS_ROUTE, + `GET /v1/projects/${COMPUTE_PROJECT_REF}`, + ]); + expect(out.promptSelectCalls).toHaveLength(1); + expect(out.stdoutText).toContain("listening on :8080"); + expect(yield* fs.exists(`${repo.dir}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "compute logs", + variant: { computeEnabled: true }, + activeFlags: { follow: false }, + requests: http.driftRequests, + }); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -398,6 +448,44 @@ describe("compute logs", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + const tailZeroRuns: ReadonlyArray<{ + readonly argv: string; + readonly kind: Option.Option<"requests">; + readonly activeFlags: Readonly>; + }> = [ + { argv: "--tail 0", kind: Option.none(), activeFlags: { tail: "0", follow: false } }, + { + argv: "--tail 0 --kind requests", + kind: Option.some("requests"), + activeFlags: { tail: "0", kind: "requests", follow: false }, + }, + ]; + + it.effect.each(tailZeroRuns)("makes no Management request for $argv", ({ kind, activeFlags }) => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, http } = setupCompute({ + workdir: repo.dir, + routes: { + [GET_COMPUTE_ROUTE]: { status: 200, body: { data: computeResource({ name: "api" }) } }, + }, + }); + + return yield* Effect.gen(function* () { + yield* computeLogs(flags({ tail: 0, kind })); + + expect(http.routeKeys).toEqual([]); + assertPermissionDrift({ + command: "compute logs", + variant: { computeEnabled: true }, + activeFlags, + requests: http.driftRequests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.effect("passes --tail through as the row limit", () => Effect.gen(function* () { const repo = yield* project(); @@ -440,7 +528,7 @@ describe("compute logs", () => { it.effect("says so when a deployed compute has simply been quiet", () => Effect.gen(function* () { const repo = yield* project(); - const { layer, out } = setupCompute({ + const { layer, out, http } = setupCompute({ workdir: repo.dir, routes: { [LOGS_ROUTE]: logsResponse([]), @@ -452,6 +540,13 @@ describe("compute logs", () => { yield* computeLogs(flags()); expect(out.stdoutText).toContain('No logs for "api" in the last 24 hours.'); + expect(http.routeKeys).toEqual([LOGS_ROUTE, GET_COMPUTE_ROUTE]); + assertPermissionDrift({ + command: "compute logs", + variant: { computeEnabled: true }, + activeFlags: { follow: false }, + requests: http.driftRequests, + }); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -809,6 +904,14 @@ describe("compute logs", () => { DateTime.toEpochMillis(DateTime.makeUnsafe(start)), ).toBeLessThan(24 * 60 * 60 * 1000); } + expect(http.routeKeys).toEqual([LOGS_ROUTE, LOGS_ROUTE, LOGS_ROUTE, LOGS_ROUTE]); + assertPermissionDrift({ + command: "compute logs", + variant: { computeEnabled: true }, + activeFlags: { follow: true }, + requests: http.driftRequests, + exact: true, + }); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -931,6 +1034,32 @@ describe("compute logs", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.effect("checks the compute, then polls, when --tail 0 --follow finds no rows", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, http } = setupCompute({ + workdir: repo.dir, + routes: { + [GET_COMPUTE_ROUTE]: { status: 200, body: { data: computeResource({ name: "api" }) } }, + [LOGS_ROUTE]: logsResponse([]), + }, + }); + + return yield* Effect.gen(function* () { + yield* computeLogs(flags({ follow: true, tail: 0 }), followFor(0)); + + expect(http.routeKeys).toEqual([GET_COMPUTE_ROUTE, LOGS_ROUTE]); + assertPermissionDrift({ + command: "compute logs", + variant: { computeEnabled: true }, + activeFlags: { tail: "0", follow: true }, + requests: http.driftRequests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + it.effect("emits a log-entry event per line under stream-json", () => Effect.gen(function* () { yield* TestClock.setTime(T3); diff --git a/apps/cli/src/commands/experimental/compute/new/new.command.ts b/apps/cli/src/commands/experimental/compute/new/new.command.ts index c4861e2c71..1fe859fe09 100644 --- a/apps/cli/src/commands/experimental/compute/new/new.command.ts +++ b/apps/cli/src/commands/experimental/compute/new/new.command.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; @@ -103,6 +104,10 @@ export const computeNewCommand = Command.make("new", config).pipe( description: "Scaffold the compute outside the compute directory", }, ]), + withPermissions({ + operations: [], + noApiEffectFlags: ["runtime", "size", "exposure", "instances", "source"], + }), Command.withHandler((flags) => computeNew(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md index a159638851..4f8f745036 100644 --- a/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/push/SIDE_EFFECTS.md @@ -21,25 +21,27 @@ and the command handler does not run. See the [Compute command guide](../../../. | `/supabase/config.json` | JSON | always when present — preferred over `config.toml`; each compute's runtime, size, exposure, instances, source. An explicit `--workdir`/`SUPABASE_WORKDIR` is read exactly as given, with no ancestor search; with a DEFAULTED workdir the loader may resolve an ancestor project's `config.json` from a subdirectory (CLI-2285), and the resolved compute `source` below resolves against that SAME ancestor root | | `/supabase/config.toml` | TOML | always when no `config.json` exists — the same compute fields, with the same explicit-vs-default workdir rule | | `/**` | any | always — packaged into the build context, minus whatever `[compute.] exclude` matches. An excluded directory is not descended into, so nothing under it is read at all | +| `/supabase/.temp/project-ref` | plain text | when neither `--project-ref` nor `SUPABASE_PROJECT_ID` is set — names the linked project | | `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | | `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | ## Files Written -| Path | Format | When | -| ----------------------------------------------- | ------ | --------------------------------------------------------------- | -| `/telemetry.json` | JSON | always — flushed on success and on failure | -| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | post-run, once the project ref has resolved (success or failure), only when no such file exists, `/supabase/.temp/project-ref` doesn't name another ref, an access token exists and `GET /v1/projects/{ref}` returns 200 | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------------------------------------- | ------------------------------------------- | --------------------------------------------------- | ------------------------------------------------------ | -| `POST` | `/v2/projects/{ref}/compute/{name}/uploads` | Bearer token | none | `data.id`, `data.attributes.url/method` | -| `PUT` | presigned upload URL (control-plane storage) | URL signature — **no** Supabase credentials | `.tar.gz` build context | status only | -| `POST` | `/v2/projects/{ref}/compute/{name}/deploy` | Bearer token | `{data:{type,attributes:{spec,context_upload_id}}}` | `data.attributes.build_state` | -| `GET` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none | `build_state`, `state_reason`, `image_version`, `spec` | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache miss only — name, org, region | +| Method | Path | Auth | Request body | Response (used fields) | +| --------------- | -------------------------------------------- | ------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST` | `/v2/projects/{ref}/compute/{name}/uploads` | Bearer token | none | `data.id`, `data.attributes.url/method` | +| `POST` or `PUT` | presigned upload URL (control-plane storage) | URL signature — **no** Supabase credentials | `.tar.gz` build context | status only. The method is the one the upload slot names: `POST` if it says so, else `PUT` | +| `POST` | `/v2/projects/{ref}/compute/{name}/deploy` | Bearer token | `{data:{type,attributes:{spec,context_upload_id}}}` | `data.attributes.build_state` | +| `GET` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none | `build_state`, `state_reason`, `image_version`, `spec` | +| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — project picker: only when no ref resolves from `--project-ref`, `SUPABASE_PROJECT_ID` or `supabase/.temp/project-ref` and stdin and stdout are TTYs | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | `GET /v2/projects/{ref}/compute/{name}` is polled until `build_state` leaves `building`. It is skipped entirely in two cases: under `--no-wait`, and when @@ -70,6 +72,7 @@ run reports the accepted spec the deploy response returned. | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `SUPABASE_EXPERIMENTAL_COMPUTE` | command registration (boolean `1`/`true` enables, `0`/`false` disables; unset/empty uses `experimental.compute`; non-boolean values are errors) | no | | `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | project ref, consulted after `--project-ref` | no (falls back to `supabase/.temp/project-ref`, then the picker) | | `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | | `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) — read exactly as given when SET (flag or env), with **no ancestor search**; a DEFAULTED workdir may still resolve an ancestor project's config from a subdirectory (CLI-2285) | | `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | @@ -170,6 +173,6 @@ An excluded symlink is excluded **before** it is vetted for escaping the build c adding a hoisted `node_modules` link to `exclude` is a real answer to `ComputeSourceEscapingLinkError` rather than something that failure pre-empts. -The presigned `PUT` above is the one request whose URL is itself a credential. +The presigned upload above is the one request whose URL is itself a credential. `--debug` logs every request URL, so `httpClientLayer` redacts query strings that carry a signature. diff --git a/apps/cli/src/commands/experimental/compute/push/push.command.ts b/apps/cli/src/commands/experimental/compute/push/push.command.ts index 0ca6246a48..d595548ed5 100644 --- a/apps/cli/src/commands/experimental/compute/push/push.command.ts +++ b/apps/cli/src/commands/experimental/compute/push/push.command.ts @@ -1,5 +1,14 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withContext, +} from "../../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../../telemetry/linked-project-cache.layer.ts"; +import { computeRead } from "../../../../shared/compute/compute-api.ts"; import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; @@ -82,6 +91,23 @@ export const computePushCommand = Command.make("push", config).pipe( description: "Deploy without blocking on the build", }, ]), + withPermissions( + compose( + { + operations: [ + { operationId: "v2-create-compute-instance-upload", kind: "required" }, + { operationId: "v2-deploy-a-compute-instance", kind: "required" }, + ], + noApiEffectFlags: ["instances", "exposure"], + }, + withContext( + withCondition(computeRead, { flag: "no-wait", is: false }), + "the deploy response reports the build as still building", + ), + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => computePush(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts b/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts index 1829d6b68a..88048aa0b0 100644 --- a/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/push/push.integration.test.ts @@ -9,6 +9,8 @@ import { COMPUTE_PROJECT_REF, type ComputeHttpRoutes, } from "../../../../../tests/helpers/compute.ts"; +import { assertPermissionDrift } from "../../../../../tests/helpers/permission-drift.ts"; +import { projectResponse } from "../../../../../tests/helpers/permission-real-layers.ts"; import { ProjectRefNotLinkedError } from "../../../../config/project-ref.errors.ts"; import { ComputeEnvNotSupportedError } from "../compute.errors.ts"; import { @@ -133,6 +135,13 @@ describe("compute push", () => { `POST ${computeRoute("/api/deploy")}`, `GET ${computeRoute("/api")}`, ]); + assertPermissionDrift({ + command: "compute push", + variant: { computeEnabled: true }, + activeFlags: { "no-wait": false }, + requests: http.driftRequests, + exact: true, + }); const deploy = http.requests.find((request) => request.url.endsWith("/deploy")); expect(yield* decodeDeploy(deploy?.body ?? "{}")).toEqual({ @@ -178,6 +187,24 @@ describe("compute push", () => { "PUT /deploy-context/api.tar.gz", `POST ${computeRoute("/api/deploy")}`, ]); + assertPermissionDrift({ + command: "compute push", + variant: { computeEnabled: true }, + activeFlags: { "no-wait": true }, + requests: http.driftRequests, + exact: true, + }); + expect(() => + assertPermissionDrift({ + command: "compute push", + variant: { computeEnabled: true }, + activeFlags: { "no-wait": true }, + requests: [ + ...http.driftRequests, + { method: "GET", url: `https://api.supabase.com${computeRoute("/api")}` }, + ], + }), + ).toThrow(/condition doesn't hold/); expect(out.stdoutText).toContain("Deployed Compute api"); // No image exists yet, so the row is dropped rather than rendered empty. @@ -187,6 +214,48 @@ describe("compute push", () => { }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); + it.effect("picks the project, deploys the compute and records the linked project", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, out, http } = setupCompute({ + workdir: repo.dir, + realLayers: true, + interactive: true, + promptSelectResponses: [COMPUTE_PROJECT_REF], + routes: routes({ + "GET /v1/projects": { status: 200, body: [projectResponse(COMPUTE_PROJECT_REF)] }, + [`GET /v1/projects/${COMPUTE_PROJECT_REF}`]: { + status: 200, + body: projectResponse(COMPUTE_PROJECT_REF), + }, + }), + }); + + return yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* push({ noWait: true }); + + expect(http.routeKeys).toEqual([ + "GET /v1/projects", + `POST ${computeRoute("/api/uploads")}`, + "PUT /deploy-context/api.tar.gz", + `POST ${computeRoute("/api/deploy")}`, + `GET /v1/projects/${COMPUTE_PROJECT_REF}`, + ]); + expect(out.promptSelectCalls).toHaveLength(1); + expect(out.stdoutText).toContain("Deployed Compute api"); + expect(yield* fs.exists(`${repo.dir}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "compute push", + variant: { computeEnabled: true }, + activeFlags: { "no-wait": true }, + requests: http.driftRequests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + // `V2DeployAWorkerOutput` permits a terminal state on the deploy response // itself, and that verdict is this deploy's. A poll on top of it can only // contradict it — `awaitComputeBuild` reads a post-deploy 404 as "still diff --git a/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md b/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md index b1d23e261f..c8e0aacbfe 100644 --- a/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/experimental/compute/status/SIDE_EFFECTS.md @@ -28,17 +28,18 @@ and the command handler does not run. See the [Compute command guide](../../../. ## Files Written -| Path | Format | When | -| ----------------------------------------------- | ------ | --------------------------------------------------------------- | -| `/telemetry.json` | JSON | always — flushed on success and on failure | -| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, when the cache does not hold it | +| Path | Format | When | +| ----------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/telemetry.json` | JSON | always — flushed on success and on failure | +| `/supabase/.temp/linked-project.json` | JSON | post-run, once the project ref has resolved (success or failure), only when no such file exists, `/supabase/.temp/project-ref` doesn't name another ref, an access token exists and `GET /v1/projects/{ref}` returns 200 | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none | `spec`, `build_state`, `state_reason`, `image_version`, `instances`, `instances_error`, `deleting` | -| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------- | ------------ | ------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v2/projects/{ref}/compute/{name}` | Bearer token | none | `spec`, `build_state`, `state_reason`, `image_version`, `instances`, `instances_error`, `deleting` | +| `GET` | `/v1/projects` | Bearer token | none | `id`, `name`, `organization_slug`, `region` — only when no ref resolved and the session is interactive, to populate the project picker | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `ref`, `name`, `organization_id`, `organization_slug` — linked-project cache miss only (best-effort, post-run): written only when `/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | ## Exit Codes diff --git a/apps/cli/src/commands/experimental/compute/status/status.command.ts b/apps/cli/src/commands/experimental/compute/status/status.command.ts index 4af2c4b9cd..b1e0838bee 100644 --- a/apps/cli/src/commands/experimental/compute/status/status.command.ts +++ b/apps/cli/src/commands/experimental/compute/status/status.command.ts @@ -1,5 +1,10 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../../telemetry/linked-project-cache.layer.ts"; +import { computeRead } from "../../../../shared/compute/compute-api.ts"; import { withCliConfigFlags } from "../../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../../config/cli-config-values.layer.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; @@ -28,6 +33,7 @@ export const computeStatusCommand = Command.make("status", config).pipe( description: "Inspect a specific compute", }, ]), + withPermissions(compose(computeRead, projectRefPicker, linkedProjectCache)), Command.withHandler((flags) => computeStatus(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts b/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts index fd9139c751..e6360674b3 100644 --- a/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts +++ b/apps/cli/src/commands/experimental/compute/status/status.integration.test.ts @@ -1,6 +1,6 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Option, Path } from "effect"; +import { Effect, FileSystem, Option, Path } from "effect"; import { makeComputeProject, setupCompute, @@ -8,6 +8,12 @@ import { computeRoute, COMPUTE_PROJECT_REF, } from "../../../../../tests/helpers/compute.ts"; +import { + assertBlockDrift, + assertPermissionDrift, +} from "../../../../../tests/helpers/permission-drift.ts"; +import { projectResponse } from "../../../../../tests/helpers/permission-real-layers.ts"; +import { computeRead } from "../../../../shared/compute/compute-api.ts"; import { InvalidComputeNameError, ComputeNotDeployedError, @@ -35,7 +41,7 @@ describe("compute status", () => { Effect.gen(function* () { const path = yield* Path.Path; const repo = yield* project(); - const { layer, out } = setupCompute({ + const { layer, out, http } = setupCompute({ workdir: repo.dir, routes: { [getRoute]: { @@ -67,6 +73,61 @@ describe("compute status", () => { expect(stdout).toContain("2/3 ready, 3 live, 1 stale"); expect(stdout).toContain(`https://${COMPUTE_PROJECT_REF}.supabase.co/compute/v1/api`); expect(stdout).toContain(path.join("supabase", "compute", "api")); + expect(http.routeKeys).toEqual([getRoute]); + assertPermissionDrift({ + command: "compute status", + variant: { computeEnabled: true }, + requests: http.driftRequests, + exact: true, + }); + assertBlockDrift(computeRead, { + requests: http.driftRequests.filter( + (request) => new URL(request.url).pathname === computeRoute("/api"), + ), + }); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), + ); + + it.effect("picks the project, reads the compute and records the linked project", () => + Effect.gen(function* () { + const repo = yield* project(); + const { layer, out, http } = setupCompute({ + workdir: repo.dir, + realLayers: true, + interactive: true, + promptSelectResponses: [COMPUTE_PROJECT_REF], + routes: { + "GET /v1/projects": { status: 200, body: [projectResponse(COMPUTE_PROJECT_REF)] }, + [`GET /v1/projects/${COMPUTE_PROJECT_REF}`]: { + status: 200, + body: projectResponse(COMPUTE_PROJECT_REF), + }, + [getRoute]: { + status: 200, + body: { data: computeResource({ name: "api", runtime: "node", imageVersion: "v3" }) }, + }, + }, + }); + + return yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* computeStatus({ name: "api", projectRef: Option.none() }); + + expect(http.routeKeys).toEqual([ + "GET /v1/projects", + getRoute, + `GET /v1/projects/${COMPUTE_PROJECT_REF}`, + ]); + expect(out.promptSelectCalls).toHaveLength(1); + expect(out.stdoutText).toContain("v3"); + expect(yield* fs.exists(`${repo.dir}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "compute status", + variant: { computeEnabled: true }, + requests: http.driftRequests, + exact: true, + }); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); @@ -265,7 +326,7 @@ describe("compute status", () => { it.effect("fails with `not deployed` and points at push", () => Effect.gen(function* () { const repo = yield* project(); - const { layer } = setupCompute({ + const { layer, http } = setupCompute({ workdir: repo.dir, routes: { [getRoute]: { @@ -286,6 +347,12 @@ describe("compute status", () => { expect(error).toBeInstanceOf(ComputeNotDeployedError); const suggestion = error instanceof ComputeNotDeployedError ? error.suggestion : ""; expect(suggestion).toContain("supabase compute push api"); + expect(http.routeKeys).toEqual([getRoute]); + assertPermissionDrift({ + command: "compute status", + variant: { computeEnabled: true }, + requests: http.driftRequests, + }); }).pipe(Effect.provide(layer)); }).pipe(Effect.scoped, Effect.provide(BunServices.layer)), ); diff --git a/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts b/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts index 2fe3d0619e..91a1f25277 100644 --- a/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts +++ b/apps/cli/src/commands/experimental/stack/destroy/destroy.command.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { machineErrorContextLayer } from "../../../../shared/output/machine-error-context.layer.ts"; import { stdinLayer } from "../../../../shared/runtime/stdin.layer.ts"; @@ -39,6 +40,7 @@ export const stackDestroyCommand = Command.make("destroy", config).pipe( description: "Permanently destroy multiple stacks by id or unique id prefix", }, ]), + withPermissions({ operations: [], noApiEffectFlags: ["stack", "stack-id"] }), Command.withHandler((flags) => stackDestroy(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/stack/list/list.command.ts b/apps/cli/src/commands/experimental/stack/list/list.command.ts index 826485911a..dd1dccbf67 100644 --- a/apps/cli/src/commands/experimental/stack/list/list.command.ts +++ b/apps/cli/src/commands/experimental/stack/list/list.command.ts @@ -1,4 +1,5 @@ import { Command } from "effect/cli"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stackList } from "./list.handler.ts"; @@ -6,6 +7,7 @@ import { stackList } from "./list.handler.ts"; export const stackListCommand = Command.make("list").pipe( Command.withDescription("List persisted managed local Supabase stacks."), Command.withShortDescription("List managed local stacks"), + withPermissions({ operations: [] }), Command.withHandler(() => stackList().pipe(withCommandTelemetry({ flags: {} }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts index 36a267b6db..0f10b13544 100644 --- a/apps/cli/src/commands/experimental/stack/logs/logs.command.ts +++ b/apps/cli/src/commands/experimental/stack/logs/logs.command.ts @@ -1,5 +1,6 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stackLogs } from "./logs.handler.ts"; @@ -67,6 +68,10 @@ export const stackLogsCommand = Command.make("logs", config).pipe( description: "Stream history and new lines as structured events", }, ]), + withPermissions({ + operations: [], + noApiEffectFlags: ["stack", "stack-id", "service", "follow", "tail", "since"], + }), Command.withHandler((flags) => stackLogs(flags).pipe( withCommandTelemetry({ flags, config, aliases: { f: "follow" } }), diff --git a/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts b/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts index 9e3fc54c12..68edf303a5 100644 --- a/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts +++ b/apps/cli/src/commands/experimental/stack/prepare/prepare.command.ts @@ -1,5 +1,6 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stackPrepare } from "./prepare.handler.ts"; @@ -39,6 +40,10 @@ export const stackPrepareCommand = Command.make("prepare", config).pipe( description: "Prepare one capability in a named stack", }, ]), + withPermissions({ + operations: [], + noApiEffectFlags: ["stack", "stack-id", "runtime", "capability"], + }), Command.withHandler((flags) => stackPrepare(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/stack/restart/restart.command.ts b/apps/cli/src/commands/experimental/stack/restart/restart.command.ts index 1ca449e6de..acbacb5124 100644 --- a/apps/cli/src/commands/experimental/stack/restart/restart.command.ts +++ b/apps/cli/src/commands/experimental/stack/restart/restart.command.ts @@ -1,5 +1,6 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stackRestart } from "./restart.handler.ts"; @@ -28,6 +29,7 @@ export const stackRestartCommand = Command.make("restart", config).pipe( description: "Restart the current project stack", }, ]), + withPermissions({ operations: [], noApiEffectFlags: ["stack", "stack-id"] }), Command.withHandler((flags) => stackRestart(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/stack/start/start.command.ts b/apps/cli/src/commands/experimental/stack/start/start.command.ts index 9b06c82fd1..ecae558a6c 100644 --- a/apps/cli/src/commands/experimental/stack/start/start.command.ts +++ b/apps/cli/src/commands/experimental/stack/start/start.command.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { machineErrorContextLayer } from "../../../../shared/output/machine-error-context.layer.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; @@ -62,6 +63,10 @@ export const stackStartCommand = Command.make("start", config).pipe( description: "Start a named Docker stack", }, ]), + withPermissions({ + operations: [], + noApiEffectFlags: ["exclude", "stack", "stack-id", "runtime", "preparation", "eager"], + }), Command.withHandler((flags) => stackStart(flags).pipe( withCommandTelemetry({ flags, config, aliases: { x: "exclude" } }), diff --git a/apps/cli/src/commands/experimental/stack/status/status.command.ts b/apps/cli/src/commands/experimental/stack/status/status.command.ts index 8dafade58e..afcf406e76 100644 --- a/apps/cli/src/commands/experimental/stack/status/status.command.ts +++ b/apps/cli/src/commands/experimental/stack/status/status.command.ts @@ -1,5 +1,6 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { stringSliceFlag } from "../../../../command-internal/string-slice-flag.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; @@ -42,6 +43,10 @@ export const stackStatusCommand = Command.make("status", config).pipe( description: "Export connection variables as dotenv", }, ]), + withPermissions({ + operations: [], + noApiEffectFlags: ["stack", "stack-id", "env", "override-name"], + }), Command.withHandler((flags) => stackStatus(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts index 38b665cdb0..e1c92a0ed8 100644 --- a/apps/cli/src/commands/experimental/stack/stop/stop.command.ts +++ b/apps/cli/src/commands/experimental/stack/stop/stop.command.ts @@ -1,5 +1,6 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../../telemetry/command-telemetry.ts"; import { stackStop } from "./stop.handler.ts"; @@ -30,6 +31,7 @@ export const stackStopCommand = Command.make("stop", config).pipe( description: "Stop the existing feature-a stack", }, ]), + withPermissions({ operations: [], noApiEffectFlags: ["all", "stack", "stack-id"] }), Command.withHandler((flags) => stackStop(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/feedback/add/add.command.ts b/apps/cli/src/commands/feedback/add/add.command.ts index 3aa6b98f18..283e44b881 100644 --- a/apps/cli/src/commands/feedback/add/add.command.ts +++ b/apps/cli/src/commands/feedback/add/add.command.ts @@ -9,6 +9,7 @@ import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts import { commandFeedbackClientLayer, feedbackCliConfigLayer } from "../feedback.layers.ts"; import { FEEDBACK_OUTPUT_FORMATS } from "../feedback-output.ts"; import { feedbackAdd } from "./add.handler.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = { message: Argument.String("message").pipe( @@ -57,6 +58,7 @@ export const feedbackAddCommand = Command.make("add", config).pipe( description: "Attribute the feedback to a project from an unlinked directory", }, ]), + withPermissions({ operations: [], noApiEffectFlags: ["project-ref"] }), Command.withHandler(feedbackAddHandler), Command.provide(commandRuntimeLayer(["feedback", "add"])), Command.provide(telemetryStateLayer), diff --git a/apps/cli/src/commands/feedback/delete/delete.command.ts b/apps/cli/src/commands/feedback/delete/delete.command.ts index bdf3f48e17..0e76cd241d 100644 --- a/apps/cli/src/commands/feedback/delete/delete.command.ts +++ b/apps/cli/src/commands/feedback/delete/delete.command.ts @@ -8,6 +8,7 @@ import { telemetryStateLayer } from "../../../telemetry/telemetry-state.layer.ts import { commandFeedbackClientLayer, feedbackCliConfigLayer } from "../feedback.layers.ts"; import { FEEDBACK_OUTPUT_FORMATS } from "../feedback-output.ts"; import { feedbackDelete } from "./delete.handler.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = { token: Argument.String("token").pipe( @@ -44,6 +45,7 @@ export const feedbackDeleteCommand = Command.make("delete", config).pipe( description: "Delete feedback using the token printed when it was submitted", }, ]), + withPermissions({ operations: [], noApiEffectFlags: ["project-ref"] }), Command.withHandler(feedbackDeleteHandler), Command.provide(commandRuntimeLayer(["feedback", "delete"])), Command.provide(telemetryStateLayer), diff --git a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md index 58df33833f..af7445b7bd 100644 --- a/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/delete/SIDE_EFFECTS.md @@ -12,16 +12,16 @@ ## Files Written -| Path | Format | When | -| ----------------------------------------------- | ------ | ----------------------------------------------------------------------- | -| `/supabase/.temp/linked-project.json` | JSON | after resolving a project ref, cached on both success and failure paths | -| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | +| Path | Format | When | +| ----------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | post-run, once a project ref has resolved (success or failure), only when no such file exists, `/supabase/.temp/project-ref` doesn't name another ref, an access token exists and `GET /v1/projects/{ref}` returns 200 | +| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | ## API Routes | Method | Path | Auth | Request body | Response (used fields) | | -------- | ------------------------------------- | ------------ | ------------ | ----------------------------------------------------- | -| `DELETE` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | none | +| `DELETE` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | none (a `404` fails with `FunctionNotFoundError`) | | `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | | `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | @@ -71,4 +71,4 @@ Prints a structured success result with the function slug and project ref. - Does NOT remove the function from the local filesystem. - Requires a linked project (`--project-ref` or linked project config). - Runs natively in TypeScript through the Management API. -- Refreshes the linked-project telemetry cache and flushes telemetry state after resolving a project ref. +- Telemetry state is flushed on success and failure. The linked-project cache is filled post-run only under the conditions listed in Files Written. diff --git a/apps/cli/src/commands/functions/delete/delete.command.ts b/apps/cli/src/commands/functions/delete/delete.command.ts index b37b234ae6..9157344f5b 100644 --- a/apps/cli/src/commands/functions/delete/delete.command.ts +++ b/apps/cli/src/commands/functions/delete/delete.command.ts @@ -1,5 +1,9 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; @@ -41,6 +45,13 @@ export const functionsDeleteCommand = Command.make("delete", config).pipe( description: "Delete a deployed function from a specific project", }, ]), + withPermissions( + compose( + { operations: [{ operationId: "v1-delete-a-function", kind: "required" }] }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler(functionsDeleteHandler), Command.provide(managementApiRuntimeLayer(["functions", "delete"])), ); diff --git a/apps/cli/src/commands/functions/delete/delete.integration.test.ts b/apps/cli/src/commands/functions/delete/delete.integration.test.ts index 3d3f8e1ebd..ebcb57081e 100644 --- a/apps/cli/src/commands/functions/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/functions/delete/delete.integration.test.ts @@ -1,21 +1,30 @@ import { describe, expect, it } from "@effect/vitest"; import { BunCrypto } from "@effect/platform-bun"; -import { Effect, Layer, Option, Stdio } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema, Stdio } from "effect"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { stripControlSequences } from "../../../shared/output/strip-control-sequences.ts"; import { + VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; -import { mockContextualAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockContextualAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { functionsDeleteHandler } from "./delete.command.ts"; import { functionsDelete } from "./delete.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-functions-delete-"); describe("functions delete", () => { @@ -48,6 +57,11 @@ describe("functions delete", () => { ); expect(linkedProjectCache.cached).toBe(true); expect(telemetry.flushed).toBe(true); + assertPermissionDrift({ + command: "functions delete", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -70,9 +84,89 @@ describe("functions delete", () => { }); expect(api.requests[0]?.url).toContain("/projects/qrstuvwxyzabcdefghij/functions/"); + assertPermissionDrift({ + command: "functions delete", + activeFlags: { "project-ref": "qrstuvwxyzabcdefghij" }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("picks a project interactively and fills the linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.url === "https://api.supabase.com/v1/projects" + ? jsonResponse(request, 200, [projectResponse(VALID_REF)]) + : request.url === `https://api.supabase.com/v1/projects/${VALID_REF}` + ? jsonResponse(request, 200, projectResponse(VALID_REF)) + : jsonResponse(request, 200, null), + ), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true, stdoutIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* functionsDelete({ functionName: "hello-world", projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/functions/hello-world`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(stripControlSequences(out.stdoutText)).toBe( + `Deleted Function hello-world from project ${VALID_REF}.\n`, + ); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "functions delete", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); + it.effect.each(["1leading-digit", "has space", "dot.name"])( + "rejects the invalid slug %j before any Management API request", + (functionName) => { + const api = mockCommandPlatformApi({ response: { status: 200, body: null } }); + const linkedProjectCache = mockLinkedProjectCacheTracked(); + const layer = buildTestRuntime({ + out: mockOutput({ format: "text" }), + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + linkedProjectCache: linkedProjectCache.layer, + }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + functionsDelete({ functionName, projectRef: Option.none() }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Cause.pretty(exit.cause)).toContain("InvalidFunctionSlugError"); + } + expect(api.requests).toEqual([]); + expect(linkedProjectCache.cached).toBe(false); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("does not redact --project-ref in cli_command_executed", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ response: { status: 200, body: null } }); diff --git a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md index fb5fb98fbc..14a39ccb36 100644 --- a/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/deploy/SIDE_EFFECTS.md @@ -38,14 +38,16 @@ Docker bundling may pull or run the configured edge-runtime image and uses the ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ------------------------------------- | ------------ | ----------------------- | ---------------------- | -| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | `[{ slug, ... }]` | -| `POST` | `/v1/projects/{ref}/functions/deploy` | Bearer token | multipart source upload | `{ id, slug, ... }` | -| `POST` | `/v1/projects/{ref}/functions` | Bearer token | bundled function body | `{ id, slug, ... }` | -| `PATCH` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | bundled function body | `{ id, slug, ... }` | -| `PUT` | `/v1/projects/{ref}/functions` | Bearer token | bulk update payload | `{ functions: [...] }` | -| `DELETE` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | ignored on `200/404` | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------- | ------------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | `[{ slug, ... }]` | +| `POST` | `/v1/projects/{ref}/functions/deploy` | Bearer token | multipart source upload | `{ id, slug, ... }` | +| `POST` | `/v1/projects/{ref}/functions` | Bearer token | bundled function body | `{ id, slug, ... }` | +| `PATCH` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | bundled function body | `{ id, slug, ... }` | +| `PUT` | `/v1/projects/{ref}/functions` | Bearer token | bulk update payload | none (only status `200` is checked) | +| `DELETE` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | ignored on `200/404` | +| `GET` | `/v1/projects` | Bearer token | none | `[{id, name, organization_slug, region}]` — project picker: no project ref from `--project-ref`, `SUPABASE_PROJECT_ID` or `supabase/.temp/project-ref`, with stdin and stdout TTYs and a non-JSON output format | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — linked-project cache miss only (best-effort, post-run): written only when `/supabase/.temp/linked-project.json` doesn't exist, `.temp/project-ref` doesn't name another ref, an access token exists and the lookup returns 200 | ## Environment Variables diff --git a/apps/cli/src/commands/functions/deploy/deploy.command.ts b/apps/cli/src/commands/functions/deploy/deploy.command.ts index 67c6ffd1fb..02d6cb77a4 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.command.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.command.ts @@ -1,6 +1,10 @@ import { Layer } from "effect"; import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../shared/functions/functions.shared.ts"; @@ -58,6 +62,11 @@ const config = { export type FunctionsDeployFlags = CliCommand.Command.Config.Infer; +const dockerPath = [ + { flag: "use-api", is: false }, + { flag: "use-docker", is: true }, +] as const; + export const functionsDeployCommand = Command.make("deploy", config).pipe( Command.withDescription("Deploy a Function to the linked Supabase project."), Command.withShortDescription("Deploy a Function to Supabase"), @@ -71,6 +80,77 @@ export const functionsDeployCommand = Command.make("deploy", config).pipe( description: "Deploy all local functions to a specific project", }, ]), + withPermissions( + compose( + { + operations: [ + { + operationId: "v1-deploy-a-function", + kind: "required", + when: [{ flag: "use-api", is: true }], + }, + { + operationId: "v1-deploy-a-function", + kind: "required", + when: [{ flag: "use-docker", is: false }], + }, + { + operationId: "v1-deploy-a-function", + kind: "required", + when: [...dockerPath], + context: "Docker isn't running (deploy falls back to API bundling)", + }, + { + operationId: "v1-list-all-functions", + kind: "required", + when: [...dockerPath], + context: "Docker is running", + }, + { + operationId: "v1-list-all-functions", + kind: "required", + when: [{ flag: "no-verify-jwt", is: false }], + context: + "on the API path (--use-api, --use-docker=false, or Docker not running), when an enabled function has no verify_jwt in config.toml", + }, + { + operationId: "v1-list-all-functions", + kind: "required", + when: [{ flag: "prune", is: true }], + }, + { + operationId: "v1-create-a-function", + kind: "required", + when: [...dockerPath], + context: + "Docker is running, a bundle differs from the deployed copy, and that function isn't deployed yet", + }, + { + operationId: "v1-update-a-function", + kind: "required", + when: [...dockerPath], + context: + "Docker is running, a bundle differs from the deployed copy, and that function is already deployed (or a create answered with a duplicated slug)", + }, + { + operationId: "v1-bulk-update-functions", + kind: "required", + context: + "two or more functions change (API path: two or more enabled functions with at least one accepted upload; Docker path: two or more changed bundles)", + }, + { + operationId: "v1-delete-a-function", + kind: "required", + when: [{ flag: "prune", is: true }], + context: "a deployed function has no local counterpart", + }, + ], + noApiEffectFlags: ["import-map", "jobs", "legacy-bundle"], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => functionsDeploy(flags).pipe( withCommandTelemetry({ flags, safeFlags: FUNCTIONS_PROJECT_REF_SAFE_FLAGS }), diff --git a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts index 7c29630bca..6847eb1edc 100644 --- a/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts +++ b/apps/cli/src/commands/functions/deploy/deploy.integration.test.ts @@ -11,6 +11,7 @@ import { Option, Path, Predicate, + Schema, Stdio, } from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; @@ -19,6 +20,7 @@ import { YesFlag } from "../../../command-internal/global-flags.ts"; import { stripControlSequences } from "../../../shared/output/strip-control-sequences.ts"; import { type ApiHandler, + VALID_REF, buildTestRuntime, jsonResponse, mockCommandSettings, @@ -30,7 +32,12 @@ import { withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; -import { mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockRuntimeInfo, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { Output } from "../../../shared/output/output.service.ts"; import { mockChildProcessSpawner } from "../../../../tests/helpers/child-process-spawner.ts"; import { @@ -51,8 +58,36 @@ import { CommandPlatformApi } from "../../../auth/command-platform-api.service.t import { functionsDeploy } from "./deploy.handler.ts"; import type { FunctionsDeployFlags } from "./deploy.command.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-functions-deploy-legacy-"); +const apiPathFlags = { + "use-api": true, + "use-docker": true, + prune: false, + "no-verify-jwt": false, +} as const; + +const dockerPathFlags = { ...apiPathFlags, "use-api": false } as const; + +const deployedFunction = (slug: string) => ({ + id: `${slug}-id`, + slug, + name: slug, + status: "ACTIVE", + version: 2, + created_at: 1_687_423_025_152, + updated_at: 1_687_423_025_152, + verify_jwt: true, + import_map: true, + entrypoint_path: `functions/${slug}/index.ts`, + import_map_path: `functions/${slug}/deno.json`, +}); + +const requestLines = (requests: ReadonlyArray<{ readonly method: string; readonly url: string }>) => + requests.map(({ method, url }) => `${method} ${url}`); + const baseFlags: FunctionsDeployFlags = { functionNames: ["hello-world"], projectRef: Option.none(), @@ -191,6 +226,148 @@ describe("functions deploy", () => { ); expect(linkedProjectCache.cached).toBe(true); expect(telemetry.flushed).toBe(true); + expect(requestLines(api.requests)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `POST https://api.supabase.com/v1/projects/${VALID_REF}/functions/deploy`, + ]); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: apiPathFlags, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.effect("picks a project interactively and fills the linked-project cache on --use-api", () => { + const out = mockOutput({ format: "text", promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.url === "https://api.supabase.com/v1/projects" + ? jsonResponse(request, 200, [projectResponse(VALID_REF)]) + : request.url === `https://api.supabase.com/v1/projects/${VALID_REF}` + ? jsonResponse(request, 200, projectResponse(VALID_REF)) + : request.method === "GET" + ? jsonResponse(request, 200, []) + : jsonResponse(request, 201, deployedFunction("hello-world")), + ), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = Layer.mergeAll( + cliConfigValuesTestLayer, + buildTestRuntime({ + out, + api, + cliSettings, + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + tty: mockTty({ stdinIsTty: true, stdoutIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }), + Layer.succeed(YesFlag, false), + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api"]), + }), + ); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* functionsDeploy(baseFlags); + + expect(requestLines(api.requests)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `POST https://api.supabase.com/v1/projects/${VALID_REF}/functions/deploy`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(stripControlSequences(out.stdoutText)).toContain( + `Deployed Functions on project ${VALID_REF}: hello-world\n`, + ); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: apiPathFlags, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.effect("skips the function list on --use-api --no-verify-jwt", () => { + const { api, layer } = apiDeployHarness(deployedHelloWorld, [ + "functions", + "deploy", + "hello-world", + "--use-api", + "--no-verify-jwt", + ]); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + yield* functionsDeploy({ ...baseFlags, noVerifyJwt: true }); + + expect(requestLines(api.requests)).toEqual([ + `POST https://api.supabase.com/v1/projects/${VALID_REF}/functions/deploy`, + ]); + const flags = { ...apiPathFlags, "no-verify-jwt": true }; + assertPermissionDrift({ + command: "functions deploy", + activeFlags: flags, + requests: api.requests, + exact: true, + }); + expect(() => + assertPermissionDrift({ + command: "functions deploy", + activeFlags: flags, + requests: [ + ...api.requests, + { method: "GET", url: `https://api.supabase.com/v1/projects/${VALID_REF}/functions` }, + ], + }), + ).toThrow(/condition doesn't hold/); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.effect("deploys through the API when --use-docker=false is passed alone", () => { + const { api, layer } = apiDeployHarness(deployedHelloWorld, [ + "functions", + "deploy", + "hello-world", + "--use-docker=false", + ]); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + yield* functionsDeploy({ ...baseFlags, useApi: false, useDocker: false }); + + expect(requestLines(api.requests)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `POST https://api.supabase.com/v1/projects/${VALID_REF}/functions/deploy`, + ]); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: { ...apiPathFlags, "use-api": false, "use-docker": false }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); @@ -530,7 +707,10 @@ describe("functions deploy", () => { }, ); - function apiDeployHarness(handler: ApiHandler) { + function apiDeployHarness( + handler: ApiHandler, + args: ReadonlyArray = ["functions", "deploy", "hello-world", "--use-api"], + ) { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ handler }); const layer = Layer.mergeAll( @@ -542,9 +722,7 @@ describe("functions deploy", () => { runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), }), Layer.succeed(YesFlag, false), - Stdio.layerTest({ - args: Effect.succeed(["functions", "deploy", "hello-world", "--use-api"]), - }), + Stdio.layerTest({ args: Effect.succeed(args) }), ); return { out, api, layer }; } @@ -932,6 +1110,41 @@ describe("functions deploy", () => { }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); + it.effect("retries a rate-limited deploy only through the command's own loop", () => { + let deployAttempts = 0; + const { layer, out, api } = apiDeployHarness((request) => { + if (request.method === "GET") return Effect.succeed(jsonResponse(request, 200, [])); + deployAttempts += 1; + if (deployAttempts === 1) { + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + new Response("{}", { status: 429, headers: { "retry-after": "0" } }), + ), + ); + } + return Effect.succeed( + jsonResponse(request, 201, { + id: "function-id", + slug: "hello-world", + name: "hello-world", + status: "ACTIVE", + version: 1, + }), + ); + }); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + yield* functionsDeploy(baseFlags); + + expect(api.requests.filter((request) => request.method === "POST")).toHaveLength(2); + expect(out.stderrText).toContain("Rate limit exceeded while deploying function hello-world."); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + it.effect("reports a malformed functions list with the schema decoder's message", () => { const { layer } = apiDeployHarness((request) => Effect.succeed( @@ -998,6 +1211,80 @@ describe("functions deploy", () => { decode: true, message: "failed to read function response: Expected a valid JSON string", }); + expect(requestLines(api.requests)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `POST https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + ]); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: dockerPathFlags, + requests: api.requests, + }); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }, + ); + + it.live( + "creates an undeployed function, updates a deployed one and bulk-updates both via Docker", + () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.method === "GET") { + return Effect.succeed( + jsonResponse(request, 200, [ + { ...deployedFunction("bye-world"), ezbr_sha256: "stale-digest" }, + ]), + ); + } + if (request.method === "PUT") { + return Effect.succeed(jsonResponse(request, 200, {})); + } + const slug = request.url.endsWith("/functions/bye-world") ? "bye-world" : "hello-world"; + return Effect.succeed( + jsonResponse(request, request.method === "POST" ? 201 : 200, deployedFunction(slug)), + ); + }, + }); + const child = mockDockerBundleSpawner(); + const layer = Layer.mergeAll( + cliConfigValuesTestLayer, + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(YesFlag, false), + child.layer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "deploy", "hello-world", "bye-world"]), + }), + ); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + yield* writeLocalFunction(tempRoot.current, "bye-world"); + + yield* functionsDeploy({ + ...baseFlags, + functionNames: ["hello-world", "bye-world"], + useApi: false, + useDocker: true, + }); + + expect(requestLines(api.requests)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `POST https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `PATCH https://api.supabase.com/v1/projects/${VALID_REF}/functions/bye-world`, + `PUT https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + ]); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: dockerPathFlags, + requests: api.requests, + }); }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }, ); @@ -1216,6 +1503,75 @@ describe("functions deploy", () => { "Do you want to delete the following Functions from your project?\n • remote-only\n\n [y/N] y\n", ); expect(api.requests.some((request) => request.method === "DELETE")).toBe(true); + expect(requestLines(api.requests)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `POST https://api.supabase.com/v1/projects/${VALID_REF}/functions/deploy`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/functions/remote-only`, + ]); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: { ...apiPathFlags, prune: true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.effect("lists remote functions only for --prune when --no-verify-jwt is passed", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.method === "POST") { + return Effect.succeed(jsonResponse(request, 201, deployedFunction("hello-world"))); + } + if (request.method === "GET") { + return Effect.succeed( + jsonResponse(request, 200, [{ ...deployedFunction("remote-only"), import_map: false }]), + ); + } + return Effect.succeed(jsonResponse(request, 200, {})); + }, + }); + const layer = Layer.mergeAll( + cliConfigValuesTestLayer, + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), + }), + Layer.succeed(YesFlag, true), + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "deploy", + "hello-world", + "--use-api", + "--no-verify-jwt", + "--prune", + "--yes", + ]), + }), + ); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + + yield* functionsDeploy({ ...baseFlags, noVerifyJwt: true, prune: true }); + + expect(requestLines(api.requests)).toEqual([ + `POST https://api.supabase.com/v1/projects/${VALID_REF}/functions/deploy`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/functions/remote-only`, + ]); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: { ...apiPathFlags, "no-verify-jwt": true, prune: true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); @@ -1226,6 +1582,7 @@ describe("functions deploy", () => { function setupBulkDeploy(opts: { readonly deployStatuses: ReadonlyArray; readonly bulkStatuses?: ReadonlyArray; + readonly functionStatuses?: Readonly>; }) { const out = mockOutput({ format: "text" }); let deployCalls = 0; @@ -1249,7 +1606,7 @@ describe("functions deploy", () => { id: `${slug}-id`, slug, name: slug, - status: "ACTIVE", + status: opts.functionStatuses?.[slug] ?? "ACTIVE", version: 2, created_at: 1_687_423_025_152, updated_at: 1_687_423_025_152, @@ -1314,6 +1671,11 @@ describe("functions deploy", () => { const bulkUpdate = api.requests.find((request) => request.method === "PUT"); expect(bulkUpdate?.body).toMatchObject([{ slug: "hello-world" }]); expect(out.stdoutText).not.toContain("Deployed Functions on project"); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: apiPathFlags, + requests: api.requests, + }); }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); @@ -1336,8 +1698,42 @@ describe("functions deploy", () => { 'unexpected deploy status 400: {"message":"rejected bye-world"}', ].join("\n"), }); + expect( + api.requests.filter( + (request) => request.method === "POST" && request.url.endsWith("/functions/deploy"), + ), + ).toHaveLength(2); expect(api.requests.some((request) => request.method === "PUT")).toBe(false); expect(out.stdoutText).not.toContain("Deployed Functions on project"); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: apiPathFlags, + requests: api.requests, + }); + }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); + }); + + it.effect("fails loudly instead of coercing a function status the API newly reports", () => { + const { api, layer } = setupBulkDeploy({ + deployStatuses: [201, 201], + functionStatuses: { "bye-world": "SUSPENDED" }, + }); + + return Effect.gen(function* () { + yield* writeCliConfig(tempRoot.current); + yield* writeLocalFunction(tempRoot.current, "hello-world"); + yield* writeLocalFunction(tempRoot.current, "bye-world"); + + const error = yield* functionsDeploy({ + ...baseFlags, + functionNames: ["hello-world", "bye-world"], + }).pipe(Effect.flip); + + expect(error).toMatchObject({ + message: "failed to read function bye-world: unexpected status SUSPENDED", + }); + const bulkUpdate = api.requests.find((request) => request.method === "PUT"); + expect(bulkUpdate?.body).toMatchObject([{ slug: "hello-world", status: "ACTIVE" }]); }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); @@ -1365,6 +1761,11 @@ describe("functions deploy", () => { ].join("\n"), }); expect(api.requests.filter((request) => request.method === "PUT")).toHaveLength(1); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: apiPathFlags, + requests: api.requests, + }); }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); }); @@ -1626,6 +2027,15 @@ describe("functions deploy", () => { expect(stripControlSequences(out.stdoutText)).toContain( "Deployed Functions on project abcdefghijklmnopqrst: hello-world\n", ); + expect(requestLines(api.requests)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `POST https://api.supabase.com/v1/projects/${VALID_REF}/functions/deploy`, + ]); + assertPermissionDrift({ + command: "functions deploy", + activeFlags: dockerPathFlags, + requests: api.requests, + }); }).pipe(Effect.provide(layer), Effect.ensuring(removeTempRoot)); }); }); diff --git a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md index f344e3ee01..cef9d1d4d6 100644 --- a/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/download/SIDE_EFFECTS.md @@ -16,22 +16,22 @@ ## Files Written -| Path | Format | When | -| --------------------------------------------------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/functions//` | bytes | for each source file returned by the API (`--use-api`, or the Docker-unbundle fallback when Docker isn't running) | -| `/supabase/.temp/output_.eszip` | bytes | Docker-unbundle path (default): downloaded eszip, extracted into `supabase/functions//...` by the edge-runtime container; removed after the attempt unless `--debug` is set | -| `/supabase/.temp/linked-project.json` | JSON | after resolving a project ref, cached on both success and failure paths | -| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | +| Path | Format | When | +| --------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/functions//` | bytes | for each source file returned by the API (server-side path: `--use-api`, `--use-docker=false`, or the Docker-unbundle fallback when Docker isn't running) | +| `/supabase/.temp/output_.eszip` | bytes | Docker-unbundle path (default): downloaded eszip, extracted into `supabase/functions//...` by the edge-runtime container; removed after the attempt unless `--debug` is set | +| `/supabase/.temp/linked-project.json` | JSON | post-run, once a project ref has resolved (success or failure), only when no such file exists, `/supabase/.temp/project-ref` doesn't name another ref, an access token exists and `GET /v1/projects/{ref}` returns 200 | +| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------ | ------------ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | function slugs, when downloading all | -| `GET` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | entrypoint path, when absent from multipart metadata (`--use-api` path only) | -| `GET` | `/v1/projects/{ref}/functions/{slug}/body` | Bearer token | none | `--use-api`: multipart function source (`Accept: multipart/form-data`). Docker-unbundle: raw eszip bytes; a `Content-Encoding: br` response is decoded transparently by the HTTP transport, not by this command | -| `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------ | ------------ | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/functions` | Bearer token | none | function slugs, when downloading all | +| `GET` | `/v1/projects/{ref}/functions/{slug}` | Bearer token | none | entrypoint path, when absent from multipart metadata (server-side path only: `--use-api`, `--use-docker=false`, or Docker not running) | +| `GET` | `/v1/projects/{ref}/functions/{slug}/body` | Bearer token | none | Server-side path: multipart function source (`Accept: multipart/form-data`). Docker-unbundle: raw eszip bytes; a `Content-Encoding: br` response is decoded transparently by the HTTP transport, not by this command | +| `GET` | `/v1/projects` | Bearer token | none | project picker options when no ref is supplied in TTY | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked project metadata used by the post-run cache | ## Subprocesses @@ -126,8 +126,8 @@ Same envelope as `json` above (including on the Docker-unbundle path). - The mutual-exclusivity check only counts flags the user explicitly passed on the command line, not `--use-docker`'s default value — so `--use-api` alone never trips the "mutually exclusive" error. -- Refreshes the linked-project telemetry cache and flushes telemetry state after resolving a - project ref. +- Telemetry state is flushed on success and failure. The linked-project cache is filled post-run + only under the conditions listed in Files Written. ### `--legacy-bundle` is removed diff --git a/apps/cli/src/commands/functions/download/download.command.ts b/apps/cli/src/commands/functions/download/download.command.ts index 3f5c043f21..a4fc1c9514 100644 --- a/apps/cli/src/commands/functions/download/download.command.ts +++ b/apps/cli/src/commands/functions/download/download.command.ts @@ -1,8 +1,13 @@ import { Effect, Layer, Option } from "effect"; import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { functionsDownload as functionsDownloadCalls } from "../../../shared/functions/download.ts"; import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; @@ -82,6 +87,17 @@ export const functionsDownloadCommand = Command.make("download", config).pipe( description: "Download all functions from a specific project", }, ]), + withPermissions( + compose( + { + operations: [], + noApiEffectFlags: ["use-api", "use-docker", "legacy-bundle"], + }, + functionsDownloadCalls, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => Option.isSome(flags.legacyBundle) ? functionsDownloadLegacyBundleHandler(flags) diff --git a/apps/cli/src/commands/functions/download/download.integration.test.ts b/apps/cli/src/commands/functions/download/download.integration.test.ts index 70852b2619..cf424642d2 100644 --- a/apps/cli/src/commands/functions/download/download.integration.test.ts +++ b/apps/cli/src/commands/functions/download/download.integration.test.ts @@ -13,6 +13,7 @@ import { Option, Path, PlatformError, + Schema, Sink, Stdio, Stream, @@ -34,11 +35,22 @@ import { withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; -import { mockContextualAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockContextualAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { + assertBlockDrift, + assertPermissionDrift, +} from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { mockChildProcessSpawner } from "../../../../tests/helpers/child-process-spawner.ts"; import { sanitizeProjectId } from "../../../shared/config/project-id.ts"; import { containerRuntimeNotFoundMessage } from "../../../command-internal/container-cli.ts"; -import { downloadFunctions } from "../../../shared/functions/download.ts"; +import { + downloadFunctions, + functionsDownload as functionsDownloadCalls, +} from "../../../shared/functions/download.ts"; import { functionsLocalConfigLoader } from "../../../command-internal/functions-local-config.ts"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { ConflictingFunctionDownloadFlagsError } from "../../../shared/functions/download.errors.ts"; @@ -49,6 +61,8 @@ import { functionsDownload } from "./download.handler.ts"; const PROJECT_ID = "abcdefghijklmnopqrst"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + /** * Sets the spawner's exit code/stdio only for the `docker run ... unbundle` * step; earlier Docker calls in the same test already resolved by the time @@ -219,6 +233,128 @@ describe("functions download", () => { ); expect(linkedProjectCache.cached).toBe(true); expect(telemetry.flushed).toBe(true); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${PROJECT_ID}/functions/hello-world/body`, + ]); + assertPermissionDrift({ command: "functions download", requests: api.requests }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("picks a project interactively and fills the linked-project cache on --use-api", () => { + const out = mockOutput({ format: "text", promptSelectResponses: [PROJECT_ID] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.url === "https://api.supabase.com/v1/projects" + ? jsonResponse(request, 200, [projectResponse(PROJECT_ID)]) + : request.url === `https://api.supabase.com/v1/projects/${PROJECT_ID}` + ? jsonResponse(request, 200, projectResponse(PROJECT_ID)) + : request.url.endsWith("/body") + ? multipartResponse(request) + : jsonResponse(request, 200, {}), + ), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true, stdoutIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }), + cliConfigValuesTestLayer, + Stdio.layerTest({ + args: Effect.succeed(["functions", "download", "hello-world", "--use-api"]), + }), + ); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const cachePath = path.join(tempRoot.current, "supabase", ".temp", "linked-project.json"); + expect(yield* fs.exists(cachePath)).toBe(false); + yield* functionsDownload({ ...baseFlags, useApi: true, useDocker: true }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${PROJECT_ID}/functions/hello-world/body`, + `GET https://api.supabase.com/v1/projects/${PROJECT_ID}`, + ]); + expect(out.stderrText).toContain( + `Downloaded Function hello-world from project ${PROJECT_ID}.`, + ); + expect( + yield* fs.readFileString( + path.join(tempRoot.current, "supabase", "functions", "hello-world", "index.ts"), + ), + ).toBe("console.log('legacy native')"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: PROJECT_ID, + }); + assertPermissionDrift({ + command: "functions download", + activeFlags: { "use-api": true }, + requests: api.requests, + }); + assertBlockDrift(functionsDownloadCalls, { + requests: api.requests.filter(({ url }) => url.includes("/functions")), + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fetches the function metadata when the downloaded body names no entrypoint", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => + request.url.endsWith("/body") + ? Effect.succeed(multipartResponse(request, { metadata: {} })) + : Effect.succeed(jsonResponse(request, 200, { entrypoint_path: "source/index.ts" })), + }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current }), + }), + cliConfigValuesTestLayer, + Stdio.layerTest({ + args: Effect.succeed([ + "functions", + "download", + "hello-world", + "--use-api", + "--project-ref", + PROJECT_ID, + ]), + }), + ); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* functionsDownload({ ...baseFlags, useApi: true, useDocker: true }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${PROJECT_ID}/functions/hello-world/body`, + `GET https://api.supabase.com/v1/projects/${PROJECT_ID}/functions/hello-world`, + ]); + expect( + yield* fs.readFileString( + path.join(tempRoot.current, "supabase", "functions", "hello-world", "index.ts"), + ), + ).toBe("console.log('legacy native')"); + assertPermissionDrift({ + command: "functions download", + activeFlags: { "use-api": true }, + requests: api.requests, + }); + assertBlockDrift(functionsDownloadCalls, { requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -485,7 +621,10 @@ describe("functions download", () => { const path = yield* Path.Path; yield* functionsDownload({ ...baseFlags, useDocker: true }); - expect(api.requests.some((request) => request.url.endsWith("/hello-world/body"))).toBe(true); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${PROJECT_ID}/functions/hello-world/body`, + ]); + assertPermissionDrift({ command: "functions download", requests: api.requests }); expect( child.spawned.some((spawned) => spawned.command === "docker" && spawned.args[0] === "run"), ).toBe(true); @@ -700,6 +839,12 @@ describe("functions download", () => { }, }), ); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${PROJECT_ID}/functions`, + `GET https://api.supabase.com/v1/projects/${PROJECT_ID}/functions/hello-world/body`, + `GET https://api.supabase.com/v1/projects/${PROJECT_ID}/functions/goodbye-world/body`, + ]); + assertPermissionDrift({ command: "functions download", requests: api.requests }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md b/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md index 68ad5aa315..7e43678ba7 100644 --- a/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/functions/list/SIDE_EFFECTS.md @@ -12,10 +12,10 @@ ## Files Written -| Path | Format | When | -| ----------------------------------------------- | ------ | ----------------------------------------------------------------------- | -| `/supabase/.temp/linked-project.json` | JSON | after resolving a project ref, cached on both success and failure paths | -| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | +| Path | Format | When | +| ----------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | post-run, once a project ref has resolved (success or failure), only when no such file exists, `/supabase/.temp/project-ref` doesn't name another ref, an access token exists and `GET /v1/projects/{ref}` returns 200 | +| `/telemetry.json` | JSON | after command completion, flushed on both success and failure paths | ## API Routes diff --git a/apps/cli/src/commands/functions/list/list.command.ts b/apps/cli/src/commands/functions/list/list.command.ts index d83c55f542..da56d6ba8c 100644 --- a/apps/cli/src/commands/functions/list/list.command.ts +++ b/apps/cli/src/commands/functions/list/list.command.ts @@ -1,5 +1,9 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { FUNCTIONS_PROJECT_REF_SAFE_FLAGS } from "../../../shared/functions/functions.shared.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; @@ -28,6 +32,13 @@ export const functionsListCommand = Command.make("list", config).pipe( description: "List all deployed functions in a specific project", }, ]), + withPermissions( + compose( + { operations: [{ operationId: "v1-list-all-functions", kind: "required" }] }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => functionsList(flags).pipe( withCommandTelemetry({ flags, safeFlags: FUNCTIONS_PROJECT_REF_SAFE_FLAGS }), diff --git a/apps/cli/src/commands/functions/list/list.integration.test.ts b/apps/cli/src/commands/functions/list/list.integration.test.ts index 89b7bce507..6c335d8285 100644 --- a/apps/cli/src/commands/functions/list/list.integration.test.ts +++ b/apps/cli/src/commands/functions/list/list.integration.test.ts @@ -1,20 +1,26 @@ import type { V1ListAllFunctionsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema } from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { ProjectRefNotLinkedError } from "../../../config/project-ref.errors.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { functionsList } from "./list.handler.ts"; @@ -31,7 +37,6 @@ const SAMPLE_FUNCTION: Functions[number] = { verify_jwt: true, import_map: false, entrypoint_path: "functions/hello-world/index.ts", - import_map_path: null, }; const PIPE_FUNCTION: Functions[number] = { @@ -55,6 +60,8 @@ const UNKNOWN_STATUS_FUNCTION = { status: "PAUSED_FOR_REBALANCE", }; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-functions-list-int-"); interface SetupOpts { @@ -245,6 +252,75 @@ describe("functions list integration", () => { yield* functionsList({ projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/functions`); + assertPermissionDrift({ command: "functions list", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("picks a project interactively and fills the linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.url === "https://api.supabase.com/v1/projects" + ? jsonResponse(request, 200, [projectResponse(VALID_REF)]) + : request.url === `https://api.supabase.com/v1/projects/${VALID_REF}` + ? jsonResponse(request, 200, projectResponse(VALID_REF)) + : jsonResponse(request, 200, [SAMPLE_FUNCTION]), + ), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true, stdoutIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* functionsList({ projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain("Hello World"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "functions list", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("skips the project picker when --project-ref is empty and a ref file exists", () => { + const savedRef = "qrstuvwxyzabcdefghij"; + const out = mockOutput(); + const api = mockCommandPlatformApi({ response: { status: 200, body: [SAMPLE_FUNCTION] } }); + const layer = buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current, projectId: Option.none() }), + tty: mockTty({ stdinIsTty: true, stdoutIsTty: true }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${tempRoot.current}/supabase/.temp`, { recursive: true }); + yield* fs.writeFileString(`${tempRoot.current}/supabase/.temp/project-ref`, savedRef); + yield* functionsList({ projectRef: Option.some("") }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${savedRef}/functions`, + ]); + assertPermissionDrift({ + command: "functions list", + activeFlags: { "project-ref": "" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -261,11 +337,17 @@ describe("functions list integration", () => { return Effect.gen(function* () { yield* functionsList({ projectRef: Option.some("qrstuvwxyzabcdefghij") }); expect(api.requests[0]?.url).toContain("/v1/projects/qrstuvwxyzabcdefghij/functions"); + assertPermissionDrift({ + command: "functions list", + activeFlags: { "project-ref": "qrstuvwxyzabcdefghij" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); it.effect("fails with FunctionsListUnexpectedStatusError on HTTP 503", () => { - const { layer } = setup({ status: 503, response: [] }); + const { layer, api } = setup({ status: 503, response: [] }); return Effect.gen(function* () { const exit = yield* Effect.exit(functionsList({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); @@ -274,6 +356,10 @@ describe("functions list integration", () => { expect(causeText).toContain("FunctionsListUnexpectedStatusError"); expect(causeText).toContain("unexpected list functions status 503"); } + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + ]); + assertPermissionDrift({ command: "functions list", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/functions/new/new.command.ts b/apps/cli/src/commands/functions/new/new.command.ts index 090e68266d..b815953218 100644 --- a/apps/cli/src/commands/functions/new/new.command.ts +++ b/apps/cli/src/commands/functions/new/new.command.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -40,6 +41,7 @@ const functionsNewRuntimeLayer = Layer.mergeAll( export const functionsNewCommand = Command.make("new", config).pipe( Command.withDescription("Create a new Function locally."), Command.withShortDescription("Create a new Function locally"), + withPermissions({ operations: [], noApiEffectFlags: ["auth"] }), Command.withHandler((flags) => functionsNew(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/functions/serve/serve.command.ts b/apps/cli/src/commands/functions/serve/serve.command.ts index 2198a9ac94..29f276c866 100644 --- a/apps/cli/src/commands/functions/serve/serve.command.ts +++ b/apps/cli/src/commands/functions/serve/serve.command.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { httpClientLayer } from "../../../auth/http-debug.layer.ts"; import { Argument, Command, Flag } from "effect/cli"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { FUNCTIONS_SERVE_INSPECT_MODES } from "../../../shared/functions/serve.ts"; @@ -70,6 +71,18 @@ const commandConfig = { export const functionsServeCommand = Command.make("serve", commandConfig).pipe( Command.withDescription("Serve all Functions locally."), Command.withShortDescription("Serve all Functions locally"), + withPermissions({ + operations: [], + noApiEffectFlags: [ + "no-verify-jwt", + "env-file", + "import-map", + "inspect", + "inspect-mode", + "inspect-main", + "all", + ], + }), Command.withHandler((flags) => functionsServe(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.command.ts b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.command.ts index 34001edba3..e715b7f5bf 100644 --- a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.command.ts +++ b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.command.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -59,6 +60,10 @@ const genBearerJwtRuntimeLayer = Layer.mergeAll( export const genBearerJwtCommand = Command.make("bearer-jwt", config).pipe( Command.withDescription("Generate a Bearer Auth JWT for accessing Data API"), Command.withShortDescription("Generate a Bearer Auth JWT for accessing Data API"), + withPermissions({ + operations: [], + noApiEffectFlags: ["role", "sub", "exp", "valid-for", "payload"], + }), Command.withHandler((flags) => genBearerJwt(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts index c30523d529..8e3b542664 100644 --- a/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts +++ b/apps/cli/src/commands/gen/bearer-jwt/bearer-jwt.integration.test.ts @@ -20,6 +20,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { GLOBAL_FLAGS } from "../../../command-internal/global-flags.ts"; import { textCliOutputFormatter } from "../../../shared/output/text-formatter.ts"; @@ -88,7 +89,7 @@ function setup(options: SetupOptions = {}) { mockStdin(options.stdinIsTty ?? false, options.pipedAnswer), Layer.succeed(DebugLogger, { debug: () => Effect.void, http: () => Effect.void }), ); - return { layer, out, telemetry }; + return { layer, out, telemetry, api }; } const jsonText = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); @@ -147,9 +148,15 @@ const testRoot = Command.make("supabase").pipe( describe("gen bearer-jwt integration", () => { it.effect("mints a token with the built-in default ES256 key when no config exists", () => { - const { layer, out } = setup(); + const { layer, out, api } = setup(); return Effect.gen(function* () { yield* genBearerJwt(baseFlags); + assertPermissionDrift({ + command: "gen bearer-jwt", + activeFlags: { "valid-for": true }, + requests: api.requests, + exact: true, + }); expect(out.stdoutText.endsWith("\n")).toBe(true); expect(out.stdoutText.indexOf("\n")).toBe(out.stdoutText.length - 1); diff --git a/apps/cli/src/commands/gen/keys/keys.command.ts b/apps/cli/src/commands/gen/keys/keys.command.ts index 5447a5a73f..4a2da702fa 100644 --- a/apps/cli/src/commands/gen/keys/keys.command.ts +++ b/apps/cli/src/commands/gen/keys/keys.command.ts @@ -1,4 +1,5 @@ import { Command, Flag } from "effect/cli"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { removedCommand } from "../../../command-internal/removed-command.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; @@ -19,6 +20,11 @@ const config = { export const genKeysCommand = Command.make("keys", config).pipe( Command.withDescription("Removed: use `supabase projects api-keys --project-ref ` instead."), Command.withShortDescription("Removed: use `projects api-keys` instead"), + // Removed: fails before any Management API call, so no flag changes what it calls. + withPermissions({ + operations: [], + noApiEffectFlags: ["override-name", "project-ref"], + }), Command.withHandler(() => removedCommand( "Use `supabase projects api-keys --project-ref ` to read a project's API keys.", diff --git a/apps/cli/src/commands/gen/signing-key/signing-key.command.ts b/apps/cli/src/commands/gen/signing-key/signing-key.command.ts index 4edbf0e377..a44e071811 100644 --- a/apps/cli/src/commands/gen/signing-key/signing-key.command.ts +++ b/apps/cli/src/commands/gen/signing-key/signing-key.command.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; @@ -61,6 +62,7 @@ export const genSigningKeyCommand = Command.make("signing-key", config).pipe( description: "Append a new key to the configured signing key file", }, ]), + withPermissions({ operations: [], noApiEffectFlags: ["algorithm", "append"] }), Command.withHandler((flags) => genSigningKey(flags).pipe(withCommandTelemetry({ flags, config }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/gen/signing-key/signing-key.integration.test.ts b/apps/cli/src/commands/gen/signing-key/signing-key.integration.test.ts index ed825e16cd..d381fc76fe 100644 --- a/apps/cli/src/commands/gen/signing-key/signing-key.integration.test.ts +++ b/apps/cli/src/commands/gen/signing-key/signing-key.integration.test.ts @@ -22,6 +22,7 @@ import { withConfigEnv, withEmptyConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; import { DebugLogger } from "../../../shared/output/debug-logger.service.ts"; import { GLOBAL_FLAGS, YesFlag } from "../../../command-internal/global-flags.ts"; @@ -107,7 +108,7 @@ function setup(options: SetupOptions = {}) { // Listed after buildTestRuntime so it overrides the real spawner from BunServices. mockGitCheckIgnore(options.gitCheckIgnoreExitCode ?? 1), ); - return { layer, out, telemetry }; + return { layer, out, telemetry, api }; } const writeConfig = Effect.fnUntraced(function* (contents: string) { @@ -207,10 +208,16 @@ const testRoot = Command.make("supabase").pipe( describe("gen signing-key integration", () => { it.effect("prints a generated key to stdout when no signing_keys_path is configured", () => { - const { layer, out } = setup(); + const { layer, out, api } = setup(); return Effect.gen(function* () { const path = yield* Path.Path; yield* genSigningKey({ algorithm: "ES256", append: false }); + assertPermissionDrift({ + command: "gen signing-key", + activeFlags: { algorithm: true }, + requests: api.requests, + exact: true, + }); const parsed = yield* Schema.decodeEffect(storedSigningKeyJson)(out.stdoutText); expect(parsed.alg).toBe("ES256"); diff --git a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md index 56387fdc9c..a82003ec15 100644 --- a/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/gen/types/SIDE_EFFECTS.md @@ -27,21 +27,24 @@ is on, `--local` resolves the project stack through `DbConfigResolver` ## Files Written -| Path | Format | When | -| ---- | ------ | ---- | -| — | — | — | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | project-ref paths (`--linked`, `--project-id`, the implicit linked fallback), via `LinkedProjectCache.cache`, when the file is absent | -No files are written. +`--local` and `--db-url` write no files. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------- | ------------ | ---------------------- | ------------------------------------------ | -| `GET` | `/v1/projects/{ref}/types/typescript` | Bearer token | none | TypeScript type definitions text | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | (presence only; `404` ⇒ branch ref) | -| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | none | `db_host`, `db_port`, `db_user`, `db_pass` | -| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` | -| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------------- | ------------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects/{ref}/types/typescript` | Bearer token | none | TypeScript type definitions text | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | non-TypeScript lookup (`404` ⇒ branch ref); also the linked-project cache fill on every project-ref path | +| `GET` | `/v1/branches/{branch_id_or_ref}` | Bearer token | none | `db_host`, `db_port`, `db_user`, `db_pass` | +| `POST` | `/v1/projects/{ref}/cli/login-role` | Bearer token | `{ read_only: false }` | temporary `role` and `password` | +| `GET` | `/v1/projects/{ref}/config/database/pooler` | Bearer token | none | primary pooler `connection_string` | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `banned_ipv4_addresses` | +| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ ipv4_addresses, requester_ip: false }` | none | +| `GET` | `/v1/projects` | Bearer token | none | project list for the ref prompt: no ref from a flag, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, and an interactive terminal | The TypeScript endpoint is called for `--linked`, `--project-id`, and the implicit linked-project fallback when `--lang=typescript`. For other languages on those @@ -55,7 +58,9 @@ target host is the project's direct `db.` host and the pooler URL matches t expected tenant and pooler domain. An explicit `--project-id` ref fetches the primary pooler config for that ref to build the fallback connection (the saved workdir `.temp/pooler-url` is ignored because the ref may differ from the linked -workdir). `--local` and `--db-url` do not call the Management API. +workdir). The network-ban calls happen only on project refs, from the third attempt +to connect as the temporary login role, and never for preview branches. `--local` and +`--db-url` do not call the Management API. ## Subprocesses diff --git a/apps/cli/src/commands/gen/types/types.command.ts b/apps/cli/src/commands/gen/types/types.command.ts index ad4f0b2eb8..c8ae5e3f38 100644 --- a/apps/cli/src/commands/gen/types/types.command.ts +++ b/apps/cli/src/commands/gen/types/types.command.ts @@ -1,5 +1,18 @@ import { Argument, Command, Flag, Param } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withContext, + type EntryCondition, +} from "../../../command-internal/command-permissions/model.ts"; +import { + dbConfigLinked, + dbConfigPoolerFallbackLinked, +} from "../../../command-internal/db-config.layer.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { parseSchemaFlags } from "../../../command-internal/schema-flags.ts"; @@ -9,7 +22,11 @@ import { PERSISTENT_VALUE_FLAG_SHORTHANDS, } from "../../../shared/cli/flag-groups.ts"; import { genTypes } from "./types.handler.ts"; -import { GEN_TYPES_LANGUAGES, genTypesLanguageFlags } from "./types.languages.ts"; +import { + GEN_TYPES_LANGUAGES, + GEN_TYPES_LANGUAGE_FLAG_NAMES, + genTypesLanguageFlags, +} from "./types.languages.ts"; import { genTypesRuntimeLayer } from "./types.layers.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; @@ -90,6 +107,16 @@ const commandConfig = { export type GenTypesFlags = CliCommand.Command.Config.Infer & Readonly>; +const NON_TYPESCRIPT = GEN_TYPES_LANGUAGES.filter((lang) => lang !== "typescript"); +const nonTypescript = { flag: "lang", oneOf: NON_TYPESCRIPT } as const; +const notBranch = "the ref names a project, not a preview branch"; +// With any of these the run uses a local or `--db-url` database, or fails before calling the API. +const remoteTarget: ReadonlyArray = [ + { flag: "local", is: false }, + { flag: "db-url", present: false }, + { flag: "postgrest-v9-compat", is: false }, +]; + export const genTypesCommand = Command.make("types", commandConfig).pipe( Command.withDescription("Generate types from Postgres schema."), Command.withShortDescription("Generate types from Postgres schema"), @@ -111,6 +138,47 @@ export const genTypesCommand = Command.make("types", commandConfig).pipe( description: "Generate types from a database URL", }, ]), + withPermissions( + remoteTarget.reduce( + (fragment, condition) => withCondition(fragment, condition), + compose( + { + operations: [ + { + operationId: "v1-generate-typescript-types", + kind: "required", + when: [{ flag: "lang", oneOf: ["typescript"] }], + }, + { operationId: "v1-get-project", kind: "required", when: [nonTypescript] }, + { + operationId: "v1-get-a-branch-config", + kind: "required", + when: [nonTypescript], + context: "the project lookup returns 404, so the ref names a preview branch", + }, + { + operationId: "v1-get-pooler-config", + kind: "required", + when: [nonTypescript], + context: `${notBranch}, the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url (--project-id ignores the saved file)`, + }, + { + operationId: "v1-get-pooler-config", + kind: "required", + when: [nonTypescript], + context: + "the ref names a preview branch and generating against its direct host fails with an IPv6 connectivity error", + }, + ], + noApiEffectFlags: ["linked", "schema", "query-timeout", ...GEN_TYPES_LANGUAGE_FLAG_NAMES], + }, + withCondition(withContext(dbConfigLinked, notBranch), nonTypescript), + withCondition(withContext(dbConfigPoolerFallbackLinked, notBranch), nonTypescript), + withCondition(projectRefPicker, { flag: "project-id", present: false }), + linkedProjectCache, + ), + ), + ), Command.withHandler((flags) => genTypes(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-id"], config: flagsConfig }), diff --git a/apps/cli/src/commands/gen/types/types.e2e.test.ts b/apps/cli/src/commands/gen/types/types.e2e.test.ts index 78997f9979..002cf43717 100644 --- a/apps/cli/src/commands/gen/types/types.e2e.test.ts +++ b/apps/cli/src/commands/gen/types/types.e2e.test.ts @@ -167,6 +167,9 @@ const waitForLocalPostgres = Effect.fnUntraced(function* (containerName: string) "PGPASSWORD=postgres", containerName, "psql", + // The entrypoint's init server listens only on the Unix socket, so TCP reaches the final server. + "-h", + "127.0.0.1", "-U", "postgres", "-d", diff --git a/apps/cli/src/commands/gen/types/types.integration.test.ts b/apps/cli/src/commands/gen/types/types.integration.test.ts index a2e93ba305..e87f133c0f 100644 --- a/apps/cli/src/commands/gen/types/types.integration.test.ts +++ b/apps/cli/src/commands/gen/types/types.integration.test.ts @@ -1,5 +1,6 @@ import { tmpdir } from "node:os"; import { describe, expect, it } from "@effect/vitest"; +import { vi } from "vitest"; import { BunPath, BunServices } from "@effect/platform-bun"; import type { V1CreateLoginRoleOutput, @@ -31,12 +32,28 @@ import { resolveWorkdir } from "../../../config/command-settings.layer.ts"; import { DnsResolverFlag, OutputFlag } from "../../../command-internal/global-flags.ts"; import { CommandPlatformApiFactory } from "../../../auth/command-platform-api-factory.service.ts"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + primaryPoolerConfigs, + provideEnv, + realDbConfigResolver, + realLinkedProjectCache, + recordingProjectHttpClient, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../../tests/helpers/permission-real-layers.ts"; +import { withReachableDirectHost } from "../../../../tests/helpers/refuse-invalid-db-hosts.ts"; import { buildTestRuntime, VALID_REF, + VALID_TOKEN, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, + mockCommandPlatformApi, mockCommandPlatformApiService, mockTelemetryStateTracked, withEnvVar, @@ -44,6 +61,7 @@ import { import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; import type { PgConnInput } from "../../../command-internal/db-connection.service.ts"; import type { DbConnectError } from "../../../command-internal/db-connection.errors.ts"; +import { DbConnection } from "../../../command-internal/db-connection.service.ts"; import { toConnectError } from "../../../command-internal/db-connection.sql-pg.layer.ts"; import type { DbConfigError } from "../../../command-internal/db-config.service.ts"; import { DbConfigResolver } from "../../../command-internal/db-config.service.ts"; @@ -62,6 +80,15 @@ import { GenTypesToolFailedError, } from "./types.generator.service.ts"; +const fixtureNet = vi.hoisted(() => ({ directHostReachable: false })); + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net, fixtureNet)), +); + const path = Effect.runSync(Effect.provide(Path.Path, BunPath.layer)); const makeWorkdir = Effect.fnUntraced(function* (prefix: string) { @@ -3018,3 +3045,393 @@ describe("gen types", () => { ); }); }); + +const BRANCH_REF = "branchbranchbranchbr"; + +const projectBody = (ref: string) => ({ + id: ref, + ref, + organization_id: "org-id", + organization_slug: "org", + name: "demo", + region: "us-east-1", + created_at: "2025-01-01T00:00:00Z", + status: "ACTIVE_HEALTHY", + database: { + host: `db.${ref}.invalid`, + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}); + +/** + * Runs `gen types` through the real database config resolver and linked-project cache, with the + * Management API recorded at the HTTP level: the typed client's requests in `api.requests`, the + * resolver's in the stubbed fetch, and the cache's in `http.calls`. The handler's own settings + * use `projectHost: "invalid"`, so the pooler fallback treats `db..invalid` as the direct host. + */ +const setupRecorded = Effect.fnUntraced(function* (opts: { + readonly args: ReadonlyArray; + /** Written to `supabase/.temp/project-ref`. */ + readonly projectRefFile?: string; + /** The ref the cache's project lookup answers for. */ + readonly cacheRef?: string; + readonly branchOf?: string; + readonly pickRef?: string; + readonly generator?: ReturnType; +}) { + const workdir = yield* makeWorkdir("supabase-gen-types-recorded-"); + yield* ensureDefaultConfig(workdir); + if (opts.projectRefFile !== undefined) { + yield* writeTempFile(workdir, "project-ref", opts.projectRefFile); + } + const out = mockOutput({ + format: "text", + interactive: opts.pickRef !== undefined, + promptSelectResponses: opts.pickRef === undefined ? undefined : [opts.pickRef], + }); + const telemetry = mockTelemetryStateTracked(); + const generator = opts.generator ?? mockGenTypesGenerator({ output: "generated" }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + const { pathname } = new URL(request.url); + if (pathname === "/v1/projects") { + return jsonResponse(request, 200, [projectBody(opts.pickRef ?? VALID_REF)]); + } + const branchMatch = /^\/v1\/branches\/(?[^/]+)$/u.exec(pathname); + if (branchMatch?.groups?.["ref"] !== undefined) { + return jsonResponse(request, 200, { + ref: branchMatch.groups["ref"], + postgres_version: "15.1", + postgres_engine: "15", + release_channel: "ga", + status: "ACTIVE_HEALTHY", + db_host: `db.${branchMatch.groups["ref"]}.invalid`, + db_port: 5432, + db_user: "postgres", + db_pass: "branch-password", + jwt_secret: "secret", + }); + } + const poolerMatch = /^\/v1\/projects\/(?[^/]+)\/config\/database\/pooler$/u.exec( + pathname, + ); + if (poolerMatch?.groups?.["ref"] !== undefined) { + return jsonResponse(request, 200, primaryPoolerConfigs(poolerMatch.groups["ref"])); + } + const typesMatch = /^\/v1\/projects\/(?[^/]+)\/types\/typescript$/u.exec(pathname); + if (typesMatch !== null) { + return jsonResponse(request, 200, { types: "export type Database = {};" }); + } + const projectMatch = /^\/v1\/projects\/(?[^/]+)$/u.exec(pathname); + if (projectMatch?.groups?.["ref"] !== undefined) { + return projectMatch.groups["ref"] === opts.branchOf + ? jsonResponse(request, 404, { message: "Project not found" }) + : jsonResponse(request, 200, projectBody(projectMatch.groups["ref"])); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); + const http = recordingProjectHttpClient(opts.cacheRef ?? VALID_REF); + const cliSettings = mockCommandSettings({ + workdir, + projectHost: "invalid", + projectId: Option.none(), + }); + const runtime = buildTestRuntime({ + out, + api, + cliSettings, + telemetry: telemetry.layer, + tty: mockTty({ stdinIsTty: opts.pickRef !== undefined }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: http.layer, + cliSettings: mockCommandSettings({ workdir, projectId: Option.none() }), + }), + }); + const layer = Layer.mergeAll( + runtime, + cliConfigValuesTestLayer, + BunServices.layer, + mockInspectSpawner().layer, + Stdio.layerTest({ args: Effect.succeed(opts.args) }), + Layer.succeed(OutputFlag, Option.none()), + Layer.succeed(DnsResolverFlag, "native" as const), + realDbConfigResolver({ + workdir, + dbConnection: Layer.succeed(DbConnection, { + connect: () => + Effect.succeed({ + exec: () => Effect.void, + execBatch: () => Effect.void, + query: () => Effect.succeed([]), + extensionExists: () => Effect.succeed(false), + copyToCsv: () => Effect.succeed(new Uint8Array()), + queryRaw: () => Effect.succeed({ fields: [], rows: [], commandTag: "" }), + }), + }), + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }), + generator.layer, + ); + const stubbed: Array = []; + const recordedRequests = () => [ + ...api.requests.map(({ method, url }) => ({ method, url })), + ...asRecordedRequests(stubbed), + ...http.calls, + ]; + const run = (effect: Effect.Effect) => + Effect.gen(function* () { + yield* stubManagementApi(stubbed, serveLinkedDatabase(opts.cacheRef ?? VALID_REF)); + return yield* effect; + }).pipe( + Effect.scoped, + Effect.provide(layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + return { workdir, out, generator, api, http, stubbed, recordedRequests, run }; +}); + +// Effective values of the switches the declaration checks (both default to false). +const DEFAULT_SWITCHES = { local: false, "postgrest-v9-compat": false }; + +const methodPaths = (requests: ReadonlyArray<{ readonly method: string; readonly url: string }>) => + requests.map(({ method, url }) => `${method} ${new URL(url).pathname}`); + +describe("gen types permissions through the real layers", () => { + it.effect("generates typescript for --project-id with one types call and the cache fill", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const ctx = yield* setupRecorded({ + args: ["gen", "types", "--project-id", VALID_REF], + }); + yield* ctx.run(genTypes(defaultFlags({ projectId: Option.some(VALID_REF) }))); + + expect(ctx.out.stdoutText).toBe("export type Database = {};"); + expect(methodPaths(ctx.api.requests)).toEqual([ + `GET /v1/projects/${VALID_REF}/types/typescript`, + ]); + expect(ctx.stubbed).toEqual([]); + expect(methodPaths(ctx.http.calls)).toEqual([`GET /v1/projects/${VALID_REF}`]); + // The cache fill also calls the project lookup; without it, no TypeScript entry may require one. + assertPermissionDrift({ + command: "gen types", + activeFlags: { ...DEFAULT_SWITCHES, "project-id": true, lang: "typescript" }, + requests: ctx.api.requests, + exact: true, + }); + expect(yield* fs.exists(`${ctx.workdir}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ + command: "gen types", + activeFlags: { ...DEFAULT_SWITCHES, "project-id": true, lang: "typescript" }, + requests: ctx.recordedRequests(), + exact: true, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("looks the project up, then reaches the database through the pooler for go", () => + Effect.gen(function* () { + const ctx = yield* setupRecorded({ + args: ["gen", "types", "--project-id", VALID_REF, "--lang", "go"], + }); + yield* ctx.run(genTypes(defaultFlags({ projectId: Option.some(VALID_REF), lang: "go" }))); + + expect(methodPaths(ctx.api.requests)).toEqual([`GET /v1/projects/${VALID_REF}`]); + expect(ctx.stubbed.map(({ method, path }) => `${method} ${path}`)).toEqual([ + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + `POST /v1/projects/${VALID_REF}/cli/login-role`, + ]); + expect(methodPaths(ctx.http.calls)).toEqual([`GET /v1/projects/${VALID_REF}`]); + expect(ctx.generator.calls[0]?.conn.host).toBe("aws-0-us-east-1.pooler.supabase.com"); + assertPermissionDrift({ + command: "gen types", + activeFlags: { ...DEFAULT_SWITCHES, "project-id": true, lang: "go" }, + requests: ctx.recordedRequests(), + exact: true, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect( + "retries through the pooler after an IPv6 failure on the direct host for --linked", + () => + Effect.gen(function* () { + const ctx = yield* setupRecorded({ + args: ["gen", "types", "--linked", "--lang", "go"], + projectRefFile: VALID_REF, + generator: sequentialGenerator([ + () => Effect.fail(ipv6Failure()), + () => Effect.succeed("type Public struct {}"), + ]), + }); + yield* ctx.run( + Effect.gen(function* () { + yield* withReachableDirectHost(fixtureNet); + yield* genTypes(defaultFlags({ linked: true, lang: "go" })); + }), + ); + + expect(ctx.out.stdoutText).toBe("type Public struct {}"); + expect(methodPaths(ctx.api.requests)).toEqual([`GET /v1/projects/${VALID_REF}`]); + expect(ctx.stubbed.map(({ method, path }) => `${method} ${path}`)).toEqual([ + `POST /v1/projects/${VALID_REF}/cli/login-role`, + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + `POST /v1/projects/${VALID_REF}/cli/login-role`, + ]); + expect(ctx.generator.calls.map((call) => call.conn.host)).toEqual([ + `db.${VALID_REF}.invalid`, + "aws-0-us-east-1.pooler.supabase.com", + ]); + assertPermissionDrift({ + command: "gen types", + activeFlags: { ...DEFAULT_SWITCHES, linked: true, lang: "go" }, + requests: ctx.recordedRequests(), + exact: true, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("falls back to the preview branch config when the project lookup returns 404", () => + Effect.gen(function* () { + const ctx = yield* setupRecorded({ + args: ["gen", "types", "--project-id", BRANCH_REF, "--lang", "go"], + branchOf: BRANCH_REF, + cacheRef: BRANCH_REF, + generator: sequentialGenerator([ + () => Effect.fail(ipv6Failure()), + () => Effect.succeed("type Public struct {}"), + ]), + }); + yield* ctx.run(genTypes(defaultFlags({ projectId: Option.some(BRANCH_REF), lang: "go" }))); + + expect(methodPaths(ctx.api.requests)).toEqual([ + `GET /v1/projects/${BRANCH_REF}`, + `GET /v1/branches/${BRANCH_REF}`, + `GET /v1/projects/${BRANCH_REF}/config/database/pooler`, + ]); + expect(ctx.stubbed).toEqual([]); + expect(ctx.generator.calls.map((call) => call.conn.host)).toEqual([ + `db.${BRANCH_REF}.invalid`, + "aws-0-us-east-1.pooler.supabase.com", + ]); + expect(ctx.generator.calls[1]?.conn.password).toBe("branch-password"); + expect(methodPaths(ctx.http.calls)).toEqual([`GET /v1/projects/${BRANCH_REF}`]); + assertPermissionDrift({ + command: "gen types", + activeFlags: { ...DEFAULT_SWITCHES, "project-id": true, lang: "go" }, + requests: ctx.recordedRequests(), + exact: true, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("asks which project to use for --linked when no ref is saved", () => + Effect.gen(function* () { + const ctx = yield* setupRecorded({ + args: ["gen", "types", "--linked"], + pickRef: VALID_REF, + }); + yield* ctx.run(genTypes(defaultFlags({ linked: true }))); + + expect(methodPaths(ctx.api.requests)).toEqual([ + "GET /v1/projects", + `GET /v1/projects/${VALID_REF}/types/typescript`, + ]); + expect(methodPaths(ctx.http.calls)).toEqual([`GET /v1/projects/${VALID_REF}`]); + assertPermissionDrift({ + command: "gen types", + activeFlags: { ...DEFAULT_SWITCHES, linked: true, lang: "typescript" }, + requests: ctx.recordedRequests(), + exact: true, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect( + "the implicit linked fallback generates from the saved ref when no target flag is given", + () => + Effect.gen(function* () { + const ctx = yield* setupRecorded({ + args: ["gen", "types"], + projectRefFile: VALID_REF, + }); + yield* ctx.run(genTypes(defaultFlags())); + + expect(methodPaths(ctx.api.requests)).toEqual([ + `GET /v1/projects/${VALID_REF}/types/typescript`, + ]); + assertPermissionDrift({ + command: "gen types", + activeFlags: { ...DEFAULT_SWITCHES, lang: "typescript" }, + requests: ctx.recordedRequests(), + exact: true, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + + const NO_API_SCENARIOS = [ + { + name: "--local", + args: ["gen", "types", "--local"], + flags: defaultFlags({ local: true }), + activeFlags: { ...DEFAULT_SWITCHES, local: true, lang: "typescript" }, + generates: true, + }, + { + name: "--db-url, whatever the language", + args: [ + "gen", + "types", + "--db-url", + "postgresql://postgres:postgres@127.0.0.1:5432/postgres", + "--lang", + "go", + ], + flags: defaultFlags({ + dbUrl: Option.some("postgresql://postgres:postgres@127.0.0.1:5432/postgres"), + lang: "go", + }), + activeFlags: { ...DEFAULT_SWITCHES, "db-url": true, lang: "go" }, + generates: true, + }, + { + name: "--postgrest-v9-compat without --db-url, which fails first", + args: ["gen", "types", "--project-id", VALID_REF, "--postgrest-v9-compat"], + flags: defaultFlags({ projectId: Option.some(VALID_REF), postgrestV9Compat: true }), + activeFlags: { + ...DEFAULT_SWITCHES, + "postgrest-v9-compat": true, + "project-id": true, + lang: "typescript", + }, + generates: false, + }, + ] as const; + + for (const scenario of NO_API_SCENARIOS) { + it.effect(`makes no Management API request for ${scenario.name}`, () => + Effect.gen(function* () { + const ctx = yield* setupRecorded({ args: scenario.args }); + const exit = yield* ctx.run(genTypes(scenario.flags)).pipe(Effect.exit); + + expect(Exit.isSuccess(exit)).toBe(scenario.generates); + if (Exit.isFailure(exit)) { + expect(String(exit.cause)).toContain( + "--postgrest-v9-compat must used together with --db-url", + ); + } + expect(ctx.generator.calls).toHaveLength(scenario.generates ? 1 : 0); + expect(ctx.recordedRequests()).toEqual([]); + assertPermissionDrift({ + command: "gen types", + activeFlags: scenario.activeFlags, + requests: ctx.recordedRequests(), + exact: true, + }); + }).pipe(Effect.provide(BunServices.layer)), + ); + } +}); diff --git a/apps/cli/src/commands/init/init.command.ts b/apps/cli/src/commands/init/init.command.ts index 0d85adb6ec..48db9ba8fd 100644 --- a/apps/cli/src/commands/init/init.command.ts +++ b/apps/cli/src/commands/init/init.command.ts @@ -1,6 +1,7 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { cliConfigProviderLayer } from "../../shared/config/cli-config-provider.layer.ts"; import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; @@ -44,6 +45,17 @@ export type InitFlags = CliCommand.Command.Config.Infer; export const initCommand = Command.make("init", config).pipe( Command.withDescription("Initialize a local project."), Command.withShortDescription("Initialize a local project"), + withPermissions({ + operations: [], + noApiEffectFlags: [ + "interactive", + "use-orioledb", + "force", + "with-vscode-workspace", + "with-vscode-settings", + "with-intellij-settings", + ], + }), Command.withHandler((flags) => init(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/issue/issue.command.ts b/apps/cli/src/commands/issue/issue.command.ts index a54308a324..2be3bddd3f 100644 --- a/apps/cli/src/commands/issue/issue.command.ts +++ b/apps/cli/src/commands/issue/issue.command.ts @@ -5,6 +5,7 @@ import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer. import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { issueBug, issueDocs, issueFeature } from "./issue.handler.ts"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; const issueNoBrowserFlag = Flag.Boolean("no-browser").pipe( Flag.withDescription("Print the issue form URL without opening a browser."), @@ -77,6 +78,20 @@ const issueBugCommand = Command.make("bug", issueBugConfig).pipe( description: "Print a prefilled issue URL with a crash report ID", }, ]), + withPermissions({ + operations: [], + noApiEffectFlags: [ + "area", + "command", + "actual-output", + "expected-behavior", + "reproduce", + "crash-report-id", + "docker-services", + "additional-context", + "no-browser", + ], + }), Command.withHandler((flags) => issueBug(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), @@ -94,6 +109,18 @@ const issueFeatureCommand = Command.make("feature", issueFeatureConfig).pipe( description: "Open a prefilled feature request", }, ]), + withPermissions({ + operations: [], + noApiEffectFlags: [ + "existing-issues", + "area", + "problem", + "proposed-solution", + "alternatives", + "additional-context", + "no-browser", + ], + }), Command.withHandler((flags) => issueFeature(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), @@ -111,6 +138,17 @@ const issueDocsCommand = Command.make("docs", issueDocsConfig).pipe( description: "Open a prefilled documentation issue", }, ]), + withPermissions({ + operations: [], + noApiEffectFlags: [ + "link", + "issue-type", + "problem", + "improvement", + "additional-context", + "no-browser", + ], + }), Command.withHandler((flags) => issueDocs(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/link/link.command.ts b/apps/cli/src/commands/link/link.command.ts index 48b07aecd9..1e64c60ddc 100644 --- a/apps/cli/src/commands/link/link.command.ts +++ b/apps/cli/src/commands/link/link.command.ts @@ -3,6 +3,11 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; import { PROJECT_REF_PATTERN } from "../../config/project-ref.service.ts"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withContext } from "../../command-internal/command-permissions/model.ts"; +import { linkServices } from "../../command-internal/link-services-core.ts"; +import { projectRefPicker } from "../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../telemetry/linked-project-cache.layer.ts"; import { withCliConfigFlags } from "../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../config/cli-config-values.layer.ts"; import { CliConfigKeys } from "../../config/cli-config-keys.ts"; @@ -72,6 +77,36 @@ export const linkCommand = Command.make("link", config).pipe( description: "Link to a branch of the currently linked project by name", }, ]), + withPermissions( + compose( + { + operations: [ + { + operationId: "v1-list-all-branches", + kind: "required", + context: + "the [ref-or-branch] argument or --project-ref is a branch name or UUID, not a 20-letter project ref", + }, + { operationId: "v1-get-project", kind: "required" }, + { + operationId: "v1-get-project-api-keys", + kind: "required", + request: { query: { reveal: "true" } }, + }, + { + operationId: "v1-list-all-branches", + kind: "best-effort", + context: + "the project lookup returns 404 (the ref is a branch), the ref was not resolved from a branch name, and supabase/.temp/linked-project.json names a different project", + }, + ], + noApiEffectFlags: ["password"], + }, + withContext(projectRefPicker, "no [ref-or-branch] argument"), + linkServices, + linkedProjectCache, + ), + ), Command.withHandler(linkHandler), Command.provide(managementApiRuntimeLayer(["link"])), Command.provide(cliConfigValuesLayer), diff --git a/apps/cli/src/commands/link/link.integration.test.ts b/apps/cli/src/commands/link/link.integration.test.ts index 83b2e285b3..6fbbd389e5 100644 --- a/apps/cli/src/commands/link/link.integration.test.ts +++ b/apps/cli/src/commands/link/link.integration.test.ts @@ -23,11 +23,14 @@ import { mockAnalytics, mockContextualAnalytics, mockOutput, + mockTty, } from "../../../tests/helpers/mocks.ts"; import { cliConfigValuesTestLayer } from "../../../tests/helpers/config-values-layer.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, + mockCommandPlatformApi, statusCodeFailure, transportFailure, mockCommandSettings, @@ -36,6 +39,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; import { link } from "./link.handler.ts"; import { linkHandler } from "./link.command.ts"; import type { LinkFlags } from "./link.command.ts"; @@ -1527,3 +1531,160 @@ describe("link integration", () => { ); }); }); + +// Records the Management API requests `link` makes, which the service-level mock above cannot. +describe("link permissions", () => { + const API = "https://api.supabase.com"; + + function setupRecording( + opts: { + readonly selectResponses?: ReadonlyArray; + } = {}, + ) { + const out = mockOutput({ format: "text", promptSelectResponses: opts.selectResponses }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + const { pathname } = new URL(request.url); + if (request.method !== "GET") { + return jsonResponse(request, 404, { message: "unexpected request" }); + } + if (pathname === "/v1/projects") { + return jsonResponse(request, 200, [HEALTHY_PROJECT]); + } + const match = /^\/v1\/projects\/([a-z]{20})(\/.*)?$/.exec(pathname); + if (match === null) { + // Tenant service probes: unreachable, which the link tolerates. + return jsonResponse(request, 404, {}); + } + const [, ref, rest = ""] = match; + if (rest === "") { + return ref === BRANCH_PROJECT_REF + ? jsonResponse(request, 404, { message: "not found" }) + : jsonResponse(request, 200, { ...HEALTHY_PROJECT, id: ref, ref }); + } + if (rest === "/branches") return jsonResponse(request, 200, [LINK_BRANCH]); + if (rest === "/api-keys") return jsonResponse(request, 200, SERVICE_KEYS); + if (rest === "/config/storage") { + return jsonResponse(request, 200, { + migrationVersion: "2026-01-01-000000", + }); + } + if (rest === "/config/database/pooler") { + return jsonResponse(request, 200, POOLER_PRIMARY); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); + const layer = Layer.merge( + buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }), + tty: mockTty({ stdinIsTty: true }), + }), + cliConfigValuesTestLayer, + ); + return { layer, api, out, workdir: tempRoot.current }; + } + + const managementCalls = (api: { requests: ReadonlyArray<{ method: string; url: string }> }) => + api.requests + .filter(({ url }) => new URL(url).origin === API) + .map(({ method, url }) => `${method} ${url.slice(API.length)}`); + + it.effect("links by --project-ref, reading the project, its keys, storage and the pooler", () => { + const { layer, api, workdir } = setupRecording(); + return Effect.gen(function* () { + yield* link(flags()); + + expect(managementCalls(api)).toEqual([ + `GET /v1/projects/${VALID_REF}`, + `GET /v1/projects/${VALID_REF}/api-keys`, + `GET /v1/projects/${VALID_REF}/config/storage`, + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + ]); + expect(api.requests.find(({ url }) => url.endsWith("/api-keys"))?.urlParams).toContain( + "reveal=true", + ); + expect(yield* readTemp(workdir, "project-ref")).toBe(VALID_REF); + assertPermissionDrift({ + command: "link", + activeFlags: { "project-ref": true, "skip-pooler": false }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the projects for the picker when no ref is given on a TTY", () => { + const { layer, api } = setupRecording({ selectResponses: [VALID_REF] }); + return Effect.gen(function* () { + yield* link(flags({ projectRef: Option.none() })); + + expect(managementCalls(api)).toEqual([ + "GET /v1/projects", + `GET /v1/projects/${VALID_REF}`, + `GET /v1/projects/${VALID_REF}/api-keys`, + `GET /v1/projects/${VALID_REF}/config/storage`, + `GET /v1/projects/${VALID_REF}/config/database/pooler`, + ]); + assertPermissionDrift({ + command: "link", + activeFlags: { "skip-pooler": false }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("resolves a branch name through the linked parent's branch list", () => { + const { layer, api, workdir } = setupRecording(); + return Effect.gen(function* () { + yield* writeLinkedParentRef(workdir, PARENT_REF); + + yield* link(flags({ refOrBranch: Option.some("feature-branch"), projectRef: Option.none() })); + + expect(managementCalls(api)).toEqual([ + `GET /v1/projects/${PARENT_REF}/branches`, + `GET /v1/projects/${BRANCH_PROJECT_REF}`, + `GET /v1/projects/${BRANCH_PROJECT_REF}/api-keys`, + `GET /v1/projects/${BRANCH_PROJECT_REF}/config/storage`, + `GET /v1/projects/${BRANCH_PROJECT_REF}/config/database/pooler`, + ]); + assertPermissionDrift({ + command: "link", + activeFlags: { "skip-pooler": false }, + requests: api.requests, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "verifies a cached parent against its branch list when linking a raw branch ref", + () => { + const { layer, api, workdir } = setupRecording(); + return Effect.gen(function* () { + yield* writeLinkedProjectCacheFile(workdir, linkedProjectCacheJson(PARENT_REF)); + + yield* link(flags({ projectRef: Option.some(BRANCH_PROJECT_REF) })); + + expect(managementCalls(api)).toEqual([ + `GET /v1/projects/${BRANCH_PROJECT_REF}`, + `GET /v1/projects/${BRANCH_PROJECT_REF}/api-keys`, + `GET /v1/projects/${BRANCH_PROJECT_REF}/config/storage`, + `GET /v1/projects/${BRANCH_PROJECT_REF}/config/database/pooler`, + `GET /v1/projects/${PARENT_REF}/branches`, + ]); + assertPermissionDrift({ + command: "link", + activeFlags: { "project-ref": true, "skip-pooler": false }, + requests: api.requests, + }); + }).pipe(Effect.provide(layer)); + }, + ); +}); diff --git a/apps/cli/src/commands/login/login.command.ts b/apps/cli/src/commands/login/login.command.ts index 40d1403f8b..177d9e7206 100644 --- a/apps/cli/src/commands/login/login.command.ts +++ b/apps/cli/src/commands/login/login.command.ts @@ -5,6 +5,9 @@ import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { loginRuntimeLayer } from "./login.layers.ts"; import { login } from "./login.handler.ts"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../command-internal/command-permissions/model.ts"; +import { loginProfile } from "../../command-internal/login-api.layer.ts"; const config = { token: Flag.String("token").pipe( @@ -26,6 +29,9 @@ export type LoginFlags = CliCommand.Command.Config.Infer; export const loginCommand = Command.make("login", config).pipe( Command.withDescription("Authenticate using an access token."), Command.withShortDescription("Authenticate using an access token"), + withPermissions( + compose({ operations: [], noApiEffectFlags: ["token", "name", "no-browser"] }, loginProfile), + ), Command.withHandler((flags) => login(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/login/login.integration.test.ts b/apps/cli/src/commands/login/login.integration.test.ts index b9edf9f1bd..135a60f33a 100644 --- a/apps/cli/src/commands/login/login.integration.test.ts +++ b/apps/cli/src/commands/login/login.integration.test.ts @@ -14,10 +14,14 @@ import { } from "../../../tests/helpers/mocks.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { ProfileFlag } from "../../command-internal/global-flags.ts"; +import { loginApiLayer } from "../../command-internal/login-api.layer.ts"; import { loginCryptoLayer } from "../../command-internal/login-crypto.layer.ts"; +import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; import { VALID_TOKEN, buildTestRuntime, + jsonResponse, + mockCommandPlatformApi, mockCommandSettings, mockCommandCredentialsTracked, mockLoginApi, @@ -47,6 +51,8 @@ interface SetupOpts { readonly pipedStdin?: string; readonly gotrueId?: string; readonly profileFails?: boolean; + /** Runs the real login API layer against this recorded HTTP mock instead of the service-level mock. */ + readonly httpApi?: Parameters[0]; readonly failTimes?: number; readonly decryptFails?: boolean; readonly keygenFails?: boolean; @@ -85,6 +91,7 @@ function setupLogin(opts: SetupOpts = {}) { profileFails: opts.profileFails, }); const analytics = mockAnalytics(); + const httpApi = opts.httpApi === undefined ? undefined : mockCommandPlatformApi(opts.httpApi); const cliSettings = mockCommandSettings({ workdir: tempRoot.current, profileEnvValue: opts.profileEnvValue, @@ -111,13 +118,15 @@ function setupLogin(opts: SetupOpts = {}) { }), credentials.layer, crypto.layer, - loginApi.layer, + httpApi === undefined + ? loginApi.layer + : loginApiLayer.pipe(Layer.provide(httpApi.httpClientLayer), Layer.provide(cliSettings)), mockStdin(isTTY, opts.pipedStdin), mockBrowser(), Layer.succeed(ProfileFlag, opts.profileFlag ?? "supabase"), ...(opts.argv !== undefined ? [Layer.succeed(CliArgs, { args: opts.argv })] : []), ); - return { layer, out, credentials, crypto, loginApi, telemetry, analytics }; + return { layer, out, credentials, crypto, loginApi, telemetry, analytics, httpApi }; } describe("login integration", () => { @@ -279,6 +288,83 @@ describe("login integration", () => { }, ); + describe("profile lookup over the real login API", () => { + const PROFILE = { gotrue_id: "gotrue-abc", primary_email: "a@b.co", username: "ab" }; + const SESSION = { + access_token: "656e6372797074656420746f6b656e", + public_key: "04abcd", + nonce: "0102030405060708090a0b0c", + }; + + it.effect("looks up the profile once with the new token after a --token login", () => { + const { layer, out, credentials, telemetry, httpApi } = setupLogin({ + httpApi: { response: { status: 200, body: PROFILE } }, + }); + return Effect.gen(function* () { + yield* login(flags({ token: Option.some(VALID_TOKEN) })); + const requests = httpApi?.requests ?? []; + + expect(credentials.savedToken).toBe(VALID_TOKEN); + expect(out.stdoutText).toContain("You are now logged in. Happy coding!"); + expect(requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/profile", + ]); + expect(requests[0]?.headers["authorization"]).toBe(`Bearer ${VALID_TOKEN}`); + expect(telemetry.stitchedDistinctId).toBe("gotrue-abc"); + assertPermissionDrift({ command: "login", activeFlags: { token: true }, requests }); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "still logs in and clears the distinct id when the profile lookup is forbidden", + () => { + const { layer, out, credentials, telemetry, analytics, httpApi } = setupLogin({ + httpApi: { response: { status: 403, body: { message: "Forbidden" } } }, + }); + return Effect.gen(function* () { + yield* login(flags({ token: Option.some(VALID_TOKEN) })); + const requests = httpApi?.requests ?? []; + + expect(requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/profile", + ]); + expect(credentials.savedToken).toBe(VALID_TOKEN); + expect(out.stdoutText).toContain("You are now logged in. Happy coding!"); + expect(telemetry.clearedDistinctId).toBe(true); + expect(telemetry.stitchedDistinctId).toBeUndefined(); + expect(analytics.captured.map((c) => c.event)).toContain(EventLoginCompleted); + assertPermissionDrift({ command: "login", activeFlags: { token: true }, requests }); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("polls the login session, then looks up the profile in the browser flow", () => { + const { layer, credentials, httpApi } = setupLogin({ + isTTY: true, + httpApi: { + handler: (request) => + Effect.succeed( + request.url.includes("/platform/cli/login/") + ? jsonResponse(request, 200, SESSION) + : jsonResponse(request, 200, PROFILE), + ), + }, + }); + return Effect.gen(function* () { + yield* login(flags()); + const requests = httpApi?.requests ?? []; + + expect(credentials.savedToken).toBe(VALID_TOKEN); + expect(requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/platform/cli/login/test-session-id?device_code=123456", + "GET https://api.supabase.com/v1/profile", + ]); + expect(requests[1]?.headers["authorization"]).toBe(`Bearer ${VALID_TOKEN}`); + assertPermissionDrift({ command: "login", requests }); + }).pipe(Effect.provide(layer)); + }); + }); + it.effect("flushes telemetry state via ensuring", () => { const { layer, telemetry } = setupLogin(); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/logout/logout.command.ts b/apps/cli/src/commands/logout/logout.command.ts index 970cdfd28a..26b5086d0c 100644 --- a/apps/cli/src/commands/logout/logout.command.ts +++ b/apps/cli/src/commands/logout/logout.command.ts @@ -4,10 +4,12 @@ import { withJsonErrorHandling } from "../../shared/output/json-error-handling.t import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { logoutRuntimeLayer } from "./logout.layers.ts"; import { logout } from "./logout.handler.ts"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; export const logoutCommand = Command.make("logout").pipe( Command.withDescription("Log out and delete access tokens locally."), Command.withShortDescription("Log out and delete access tokens locally"), + withPermissions({ operations: [] }), Command.withHandler(() => logout().pipe(withCommandTelemetry(), withJsonErrorHandling)), Command.provide(logoutRuntimeLayer), ); diff --git a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md index 26e11243d9..a28c429c95 100644 --- a/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/migration/squash/SIDE_EFFECTS.md @@ -74,13 +74,14 @@ live shadow available. ## API Routes -| Method | Path | Auth | Purpose | -| ----------- | ----------------------------------------------------------------------------- | ------ | ---------------------------------------------------------- | -| — | — | — | local target: none | -| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | `--linked`: temp login role when no password | -| GET | `/v1/projects/{ref}/config/database/pooler` | Bearer | `--linked`: IPv4 pooler fallback (IPv6-only network) | -| POST/DELETE | `/v1/projects/{ref}/network-bans/retrieve`, `/v1/projects/{ref}/network-bans` | Bearer | `--linked`: unban during pooler login retry | -| GET | `/v1/projects/{ref}` | Bearer | `--linked`: linked-project cache (post-run, unconditional) | +| Method | Path | Auth | Purpose | +| ------ | ------------------------------------------- | ------ | ---------------------------------------------------------- | +| — | — | — | local target: none | +| POST | `/v1/projects/{ref}/cli/login-role` | Bearer | `--linked`: temp login role when no password | +| GET | `/v1/projects/{ref}/config/database/pooler` | Bearer | `--linked`: IPv4 pooler fallback (IPv6-only network) | +| POST | `/v1/projects/{ref}/network-bans/retrieve` | Bearer | `--linked`: list bans during pooler login retry | +| DELETE | `/v1/projects/{ref}/network-bans` | Bearer | `--linked`: unban during pooler login retry | +| GET | `/v1/projects/{ref}` | Bearer | `--linked`: linked-project cache (post-run, unconditional) | ## Environment Variables diff --git a/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md b/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md index e8e1f972ac..7d7352c82c 100644 --- a/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/get/SIDE_EFFECTS.md @@ -2,34 +2,37 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------ | ------------ | ------------ | ------------------------- | -| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `{banned_ipv4_addresses}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------ | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `POST` | `/v1/projects/{ref}/network-bans/retrieve` | Bearer token | none | `{banned_ipv4_addresses}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | The Management API exposes this read operation as `POST .../network-bans/retrieve` (not `GET`) — see `V1ListAllNetworkBans` in `packages/api/src/generated/contracts.ts`. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -77,7 +80,7 @@ One `result` event whose `data` is the full response object. ## Notes - The `--output` flag wins over `--output-format` when both are provided. -- `linked-project.json` is written **after** the project ref is resolved, regardless of whether the subsequent API call succeeds. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is open), even when the API call fails, but only on a cache miss with a token when `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed gate writes nothing. - `telemetry.json` is written on every invocation, including failures, but only once the `--experimental` gate is open. - Network bans are temporary blocks on IPs with abusive traffic patterns (e.g. multiple failed auth attempts). - `network-bans` is an experimental command: `get` requires `--experimental` (or diff --git a/apps/cli/src/commands/network-bans/get/get.command.ts b/apps/cli/src/commands/network-bans/get/get.command.ts index f24dbb593d..303c5bae85 100644 --- a/apps/cli/src/commands/network-bans/get/get.command.ts +++ b/apps/cli/src/commands/network-bans/get/get.command.ts @@ -2,6 +2,10 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/output-formats.ts"; @@ -24,6 +28,15 @@ export type NetworkBansGetFlags = CliCommand.Command.Config.Infer export const networkBansGetCommand = Command.make("get", config).pipe( Command.withDescription("Get the current network bans."), Command.withShortDescription("Get the current network bans"), + withPermissions( + compose( + { + operations: [{ operationId: "v1-list-all-network-bans", kind: "required" }], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => Effect.gen(function* () { // Validate the -o value before the --experimental gate, so an invalid value is diff --git a/apps/cli/src/commands/network-bans/get/get.integration.test.ts b/apps/cli/src/commands/network-bans/get/get.integration.test.ts index b1642c4a2e..bcdb3c800a 100644 --- a/apps/cli/src/commands/network-bans/get/get.integration.test.ts +++ b/apps/cli/src/commands/network-bans/get/get.integration.test.ts @@ -1,20 +1,28 @@ import { type V1ListAllNetworkBansOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { networkBansGet } from "./get.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const SAMPLE_BANS: typeof V1ListAllNetworkBansOutput.Type = { banned_ipv4_addresses: ["192.168.0.1", "192.168.0.2"], }; @@ -185,6 +193,83 @@ describe("network-bans get integration", () => { expect(api.requests).toHaveLength(1); expect(api.requests[0]?.method).toBe("POST"); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/network-bans/retrieve`); + assertPermissionDrift({ command: "network-bans get", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the picker's projects, the bans and fills the real linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, 201, SAMPLE_BANS)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* networkBansGet({ projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `POST https://api.supabase.com/v1/projects/${VALID_REF}/network-bans/retrieve`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toBe('[\n "192.168.0.1",\n "192.168.0.2"\n]\n'); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "network-bans get", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("ignores an empty --project-ref and resolves the saved ref without the picker", () => { + const out = mockOutput(); + const api = mockCommandPlatformApi({ response: { status: 201, body: SAMPLE_BANS } }); + const layer = buildTestRuntime({ + out, + api, + cliSettings: mockCommandSettings({ workdir: tempRoot.current, projectId: Option.none() }), + tty: mockTty({ stdinIsTty: true }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${tempRoot.current}/supabase/.temp`, { recursive: true }); + yield* fs.writeFileString(`${tempRoot.current}/supabase/.temp/project-ref`, VALID_REF); + yield* networkBansGet({ projectRef: Option.some("") }); + expect(out.promptSelectCalls).toHaveLength(0); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `POST https://api.supabase.com/v1/projects/${VALID_REF}/network-bans/retrieve`, + ]); + assertPermissionDrift({ + command: "network-bans get", + activeFlags: { "project-ref": "" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md b/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md index e935a6cf8b..d25c1cb75e 100644 --- a/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-bans/remove/SIDE_EFFECTS.md @@ -2,34 +2,37 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed or if flag parsing fails (malformed `--db-unban-ip` CSV). | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed or if flag parsing fails (malformed `--db-unban-ip` CSV). | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | --------------------------------- | ------------ | --------------------------------------------------- | ---------------------- | -| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ipv4_addresses: string[], requester_ip: boolean}` | none | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | --------------------------------- | ------------ | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `DELETE` | `/v1/projects/{ref}/network-bans` | Bearer token | `{ipv4_addresses: string[], requester_ip: boolean}` | none | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | `requester_ip` is `true` when no `--db-unban-ip` flags are passed (self-unban mode) and `false` otherwise. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -77,8 +80,8 @@ One `result` event on success when the `--output` flag is unset. - The `--output` flag wins over `--output-format` when both are provided. - Requires `--db-unban-ip` flag to specify IP(s) to unban (repeatable). When omitted, the caller's own IP is unbanned (`requester_ip: true`). -- Requires `--project-ref` or a linked project (`.supabase/config.json`). -- `linked-project.json` is written **after** the project ref is resolved, regardless of whether the subsequent API call succeeds. +- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`), or, on an interactive terminal, the project picker. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is open), even when the API call fails, but only on a cache miss with a token when `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed gate writes nothing. - `telemetry.json` is written on every invocation that reaches the handler, including failures, but only once the `--experimental` gate is open. A malformed `--db-unban-ip` CSV value (e.g. an unterminated quote) fails during flag parsing — before the gate, the handler, both file writes, and the `cli_command_executed` event — even when `--experimental` is passed. - `network-bans` is an experimental command: `remove` requires `--experimental` (or `SUPABASE_EXPERIMENTAL`), checked before the login check. diff --git a/apps/cli/src/commands/network-bans/remove/remove.command.ts b/apps/cli/src/commands/network-bans/remove/remove.command.ts index 306774e405..02672ee210 100644 --- a/apps/cli/src/commands/network-bans/remove/remove.command.ts +++ b/apps/cli/src/commands/network-bans/remove/remove.command.ts @@ -2,6 +2,10 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/output-formats.ts"; @@ -36,6 +40,16 @@ export type NetworkBansRemoveFlags = CliCommand.Command.Config.Infer Effect.gen(function* () { // Validate the -o value before the --experimental gate, so an invalid value is diff --git a/apps/cli/src/commands/network-bans/remove/remove.integration.test.ts b/apps/cli/src/commands/network-bans/remove/remove.integration.test.ts index 01fdb4acaf..94a80b7dbf 100644 --- a/apps/cli/src/commands/network-bans/remove/remove.integration.test.ts +++ b/apps/cli/src/commands/network-bans/remove/remove.integration.test.ts @@ -1,21 +1,29 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { networkBansRemoveDbUnbanIpFlag } from "./remove.command.ts"; import { networkBansRemove } from "./remove.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + // Runs the real --db-unban-ip flag pipeline so these scenarios cover raw CLI values // through to the request body. const parseDbUnbanIp = (rawValues: ReadonlyArray) => @@ -81,6 +89,57 @@ describe("network-bans remove integration", () => { expect(api.requests[0]?.method).toBe("DELETE"); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/network-bans`); expect(api.requests[0]?.body).toEqual({ ipv4_addresses: [], requester_ip: true }); + assertPermissionDrift({ + command: "network-bans remove", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the picker's projects, removes the bans and fills the real cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, 200, null)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* networkBansRemove({ projectRef: Option.none(), dbUnbanIp: [] }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/network-bans`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toBe("Successfully removed network bans.\n"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "network-bans remove", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -95,6 +154,11 @@ describe("network-bans remove integration", () => { ipv4_addresses: ["12.3.4.5", "2001:db8:abcd:0012::0"], requester_ip: false, }); + assertPermissionDrift({ + command: "network-bans remove", + activeFlags: { "db-unban-ip": true }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md b/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md index 2081c28045..722ff71a54 100644 --- a/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-restrictions/get/SIDE_EFFECTS.md @@ -2,32 +2,35 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------------- | ------------ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/network-restrictions` | Bearer token | none | `{ config: { dbAllowedCidrs?: string[], dbAllowedCidrsV6?: string[] }, status: "stored" \| "applied" }` (see `V1GetNetworkRestrictionsOutput` in `packages/api`) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/network-restrictions` | Bearer token | none | `{ config: { dbAllowedCidrs?: string[], dbAllowedCidrsV6?: string[] }, status: "stored" \| "applied" }` (see `V1GetNetworkRestrictionsOutput` in `packages/api`) | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -92,7 +95,6 @@ One `result` event whose `data` is the full response object. - The `--output` flag wins over `--output-format` when both are provided (see `--output` above). -- `linked-project.json` is written **after** the project ref is resolved, regardless of - whether the subsequent API call succeeds. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is open), even when the API call fails, but only on a cache miss with a token when `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed gate writes nothing. - `telemetry.json` is written on every invocation past the `--experimental` gate, including failures. A closed gate writes nothing. diff --git a/apps/cli/src/commands/network-restrictions/get/get.command.ts b/apps/cli/src/commands/network-restrictions/get/get.command.ts index 630909cd1a..077ab42e80 100644 --- a/apps/cli/src/commands/network-restrictions/get/get.command.ts +++ b/apps/cli/src/commands/network-restrictions/get/get.command.ts @@ -2,6 +2,10 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/output-formats.ts"; @@ -24,6 +28,15 @@ export type NetworkRestrictionsGetFlags = CliCommand.Command.Config.Infer Effect.gen(function* () { // Validate the -o value before the --experimental gate, so an invalid value is diff --git a/apps/cli/src/commands/network-restrictions/get/get.integration.test.ts b/apps/cli/src/commands/network-restrictions/get/get.integration.test.ts index 1d69c4a133..df85c7de04 100644 --- a/apps/cli/src/commands/network-restrictions/get/get.integration.test.ts +++ b/apps/cli/src/commands/network-restrictions/get/get.integration.test.ts @@ -1,20 +1,28 @@ import { type V1GetNetworkRestrictionsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { networkRestrictionsGet } from "./get.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const APPLIED_WITH_CIDRS: typeof V1GetNetworkRestrictionsOutput.Type = { entitlement: "allowed", config: { @@ -228,6 +236,61 @@ describe("network-restrictions get integration", () => { expect(api.requests).toHaveLength(1); expect(api.requests[0]?.method).toBe("GET"); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/network-restrictions`); + assertPermissionDrift({ + command: "network-restrictions get", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the picker's projects, the restrictions and fills the real cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, 200, APPLIED_WITH_CIDRS)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* networkRestrictionsGet({ projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/network-restrictions`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toBe( + "DB Allowed IPv4 CIDRs: &[1.2.3.0/24 5.6.7.0/24]\n" + + "DB Allowed IPv6 CIDRs: &[2001:db8::/64]\n" + + "Restrictions applied successfully: true\n", + ); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "network-restrictions get", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/network-restrictions/network-restrictions.cidr.ts b/apps/cli/src/commands/network-restrictions/network-restrictions.cidr.ts index c17aeb88d3..34dcf3a56e 100644 --- a/apps/cli/src/commands/network-restrictions/network-restrictions.cidr.ts +++ b/apps/cli/src/commands/network-restrictions/network-restrictions.cidr.ts @@ -142,7 +142,7 @@ export function validateAndPartitionCidrs( * the two flat string arrays the output template expects. */ export function partitionPatchedCidrs( - items: ReadonlyArray<{ readonly address: string; readonly type: "v4" | "v6" }> | undefined, + items: ReadonlyArray<{ readonly address: string; readonly type: string }> | undefined, ): { readonly v4: string[]; readonly v6: string[] } { const v4: string[] = []; const v6: string[] = []; diff --git a/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md b/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md index e805514469..9e71d43ffe 100644 --- a/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/network-restrictions/update/SIDE_EFFECTS.md @@ -2,24 +2,27 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — success and HTTP failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via outermost `Effect.ensuring` — including CIDR validation failures. Not written if closed or if flag parsing fails (malformed `--db-allow-cidr` CSV). | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves (only if CIDR validation passes), on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via outermost `Effect.ensuring` — including CIDR validation failures. Not written if closed or if flag parsing fails (malformed `--db-allow-cidr` CSV). | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------- | ----------------------------------------------- | ------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------ | -| `POST` | `/v1/projects/{ref}/network-restrictions/apply` | Bearer token | `{ dbAllowedCidrs: string[], dbAllowedCidrsV6: string[] }` | `{ config: { dbAllowedCidrs?, dbAllowedCidrsV6? }, status }` (`V1UpdateNetworkRestrictionsOutput`) | -| `PATCH` | `/v1/projects/{ref}/network-restrictions` | Bearer token | `{ add: { dbAllowedCidrs: string[], dbAllowedCidrsV6: string[] } }` | `{ config: { dbAllowedCidrs?: Array<{address, type}> }, status }` (`V1PatchNetworkRestrictionsOutput`) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------- | ----------------------------------------------- | ------------ | ------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `POST` | `/v1/projects/{ref}/network-restrictions/apply` | Bearer token | `{ dbAllowedCidrs: string[], dbAllowedCidrsV6: string[] }` | `{ config: { dbAllowedCidrs?, dbAllowedCidrsV6? }, status }` (`V1UpdateNetworkRestrictionsOutput`) | +| `PATCH` | `/v1/projects/{ref}/network-restrictions` | Bearer token | `{ add: { dbAllowedCidrs: string[], dbAllowedCidrsV6: string[] } }` | `{ config: { dbAllowedCidrs?: Array<{address, type}> }, status }` (`V1PatchNetworkRestrictionsOutput`) | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | `POST /apply` is the default (replace mode). `PATCH` is used when `--append=true`. @@ -28,12 +31,12 @@ when no `--db-allow-cidr` was supplied). ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -112,8 +115,7 @@ One `result` event whose `data` is the full response object. (see `--output` above). - `--append=true` switches the HTTP method (`POST /apply` → `PATCH`) and the request envelope (`{ dbAllowedCidrs, dbAllowedCidrsV6 }` → `{ add: { dbAllowedCidrs, dbAllowedCidrsV6 } }`). -- `linked-project.json` writes after a successful project-ref resolution, regardless of - whether the subsequent API call succeeds. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is open), even when the API call fails, but only on a cache miss with a token when `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed gate writes nothing. - `telemetry.json` writes on every invocation past the `--experimental` gate, including CIDR validation failures, ref resolution failures, and API failures. A closed gate writes nothing. A malformed `--db-allow-cidr` CSV value (e.g. an unterminated quote) also diff --git a/apps/cli/src/commands/network-restrictions/update/update.command.ts b/apps/cli/src/commands/network-restrictions/update/update.command.ts index 2b99a2a6da..3df25e05dc 100644 --- a/apps/cli/src/commands/network-restrictions/update/update.command.ts +++ b/apps/cli/src/commands/network-restrictions/update/update.command.ts @@ -2,6 +2,10 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/output-formats.ts"; @@ -44,6 +48,27 @@ export type NetworkRestrictionsUpdateFlags = CliCommand.Command.Config.Infer Effect.gen(function* () { // Validate the -o value before the --experimental gate, so an invalid value is diff --git a/apps/cli/src/commands/network-restrictions/update/update.integration.test.ts b/apps/cli/src/commands/network-restrictions/update/update.integration.test.ts index 5b65aff842..a4fd9377d6 100644 --- a/apps/cli/src/commands/network-restrictions/update/update.integration.test.ts +++ b/apps/cli/src/commands/network-restrictions/update/update.integration.test.ts @@ -4,13 +4,14 @@ import { type V1UpdateNetworkRestrictionsOutput, } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, type MockCommandPlatformApiOpts, @@ -18,12 +19,19 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { type NetworkRestrictionsUpdateFlags, networkRestrictionsUpdateDbAllowCidrFlag, } from "./update.command.ts"; import { networkRestrictionsUpdate } from "./update.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + // Runs the real --db-allow-cidr flag pipeline so these scenarios cover raw CLI values // through to the request body. const parseDbAllowCidr = (rawValues: ReadonlyArray) => @@ -149,9 +157,92 @@ describe("network-restrictions update integration", () => { "DB Allowed IPv6 CIDRs: &[2001:db8:abcd:0012::0/64]\n" + "Restrictions applied successfully: true\n", ); + assertPermissionDrift({ + command: "network-restrictions update", + activeFlags: { append: false }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); + it.effect("declares POST /apply only when --append is off", () => { + const { layer, api } = setup({ postResponse: POST_APPLIED }); + return Effect.gen(function* () { + yield* networkRestrictionsUpdate({ ...baseFlags, dbAllowCidr: ["12.3.4.5/32"] }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `POST https://api.supabase.com/v1/projects/${VALID_REF}/network-restrictions/apply`, + ]); + expect(() => + assertPermissionDrift({ + command: "network-restrictions update", + activeFlags: { append: true }, + requests: api.requests, + }), + ).toThrow(/condition doesn't hold/); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "lists the picker's projects, applies the restrictions and fills the real cache", + () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, 201, POST_APPLIED)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* networkRestrictionsUpdate({ + ...baseFlags, + dbAllowCidr: ["12.3.4.5/32", "2001:db8:abcd:0012::0/64", "1.2.3.1/24"], + }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `POST https://api.supabase.com/v1/projects/${VALID_REF}/network-restrictions/apply`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toBe( + "DB Allowed IPv4 CIDRs: &[12.3.4.5/32 1.2.3.1/24]\n" + + "DB Allowed IPv6 CIDRs: &[2001:db8:abcd:0012::0/64]\n" + + "Restrictions applied successfully: true\n", + ); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "network-restrictions update", + activeFlags: { append: false }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("sends empty arrays when no --db-allow-cidr is provided", () => { const { layer, api } = setup({ postResponse: POST_EMPTY_APPLIED }); return Effect.gen(function* () { @@ -280,6 +371,31 @@ describe("network-restrictions update integration", () => { "DB Allowed IPv6 CIDRs: &[2001:db8:abcd:0012::0/64]\n" + "Restrictions applied successfully: true\n", ); + assertPermissionDrift({ + command: "network-restrictions update", + activeFlags: { append: true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("declares PATCH only when --append is on", () => { + const { layer, api } = setup({ patchResponse: PATCH_APPLIED }); + return Effect.gen(function* () { + yield* networkRestrictionsUpdate({ + ...baseFlags, + append: true, + dbAllowCidr: ["12.3.4.5/32"], + }); + expect(api.requests.map(({ method }) => method)).toEqual(["PATCH"]); + expect(() => + assertPermissionDrift({ + command: "network-restrictions update", + activeFlags: { append: false }, + requests: api.requests, + }), + ).toThrow(/condition doesn't hold/); }).pipe(Effect.provide(layer)); }); @@ -390,6 +506,12 @@ describe("network-restrictions update integration", () => { dbAllowedCidrs: ["10.0.0.0/8"], dbAllowedCidrsV6: [], }); + assertPermissionDrift({ + command: "network-restrictions update", + activeFlags: { append: false, "bypass-cidr-checks": true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/notebooks/notebooks.shared.ts b/apps/cli/src/commands/notebooks/notebooks.shared.ts index 2f097c33e6..49228c549f 100644 --- a/apps/cli/src/commands/notebooks/notebooks.shared.ts +++ b/apps/cli/src/commands/notebooks/notebooks.shared.ts @@ -1,6 +1,7 @@ import type { ApiClient } from "@supabase/api/effect"; import { V2GetNotebookInput, V2UpdateNotebookInput } from "@supabase/api/effect"; import { Effect, Exit, FileSystem, Option, Path, Schema, Stream } from "effect"; +import { block } from "../../command-internal/command-permissions/model.ts"; import { Output } from "../../shared/output/output.service.ts"; import { notebooksMachineOutputRequested } from "./notebooks.output.ts"; import { sanitizeInlineName, mapHttpError } from "../../command-internal/http-errors.ts"; @@ -31,6 +32,21 @@ import { * refused rather than guessed at (`NotebookNameConflictError`). */ +/** `listRemoteNotebooks`: every page of the project's notebooks. */ +export const notebooksList = block("notebooks-list", [ + { operationId: "v2-list-notebooks", kind: "required" }, +]); + +/** `downloadNotebook` / `downloadNotebookById`: one notebook's content. */ +export const notebookRead = block("notebook-read", [ + { operationId: "v2-get-notebook", kind: "required" }, +]); + +/** `uploadNotebook` for a name no project notebook carries. */ +export const notebookCreate = block("notebook-create", [ + { operationId: "v2-create-notebook", kind: "required" }, +]); + /** `supabase/notebooks/`, alongside `supabase/functions/` and `supabase/workers/`. */ export const notebooksDir = Effect.fnUntraced(function* (workdir: string) { const path = yield* Path.Path; @@ -441,13 +457,23 @@ const readRemoteNotebook = Effect.fnUntraced(function* ( ); const { description, favorite, content, name } = response.data.attributes; + // Response enums are open; the file must hold values a push can send back. + const file = yield* Schema.decodeUnknownEffect(NotebookFileSchema)({ + ...(description === null ? {} : { description }), + favorite, + content: { cells: content.cells }, + }).pipe( + Effect.mapError( + (cause) => + new NotebookFileError({ + detail: `Notebook ${subject} has content this CLI cannot write to a file: ${cause.message}`, + suggestion: "Update the CLI, or edit the notebook in the dashboard.", + }), + ), + ); return { notebook: { id: response.data.id, name } satisfies RemoteNotebook, - file: { - ...(description === null ? {} : { description }), - favorite, - content: { cells: content.cells }, - } satisfies NotebookFile, + file, }; }); diff --git a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md index 7c6a1b5a7d..7b73d07277 100644 --- a/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/pull/SIDE_EFFECTS.md @@ -7,21 +7,24 @@ the timestamps, the `owner` / `updated_by` identities, and `content.schema_versi ## Files Read -| Path | Format | When | -| ------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------- | -| `/supabase/notebooks/` | dir | when no notebook id is given — to preserve existing notebooks and find local-only notebooks | -| `/supabase/notebooks/.json` | JSON | only for a local-only notebook the user chose to create in the project | -| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | -| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | +| Path | Format | When | +| ---------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `/supabase/notebooks/` | dir | when no notebook id is given — to preserve existing notebooks and find local-only notebooks | +| `/supabase/notebooks/.json` | JSON | only for a local-only notebook the user chose to create in the project | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/linked-project.json` | JSON | existence check before the cache fill, once the project ref resolves | +| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | +| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | ## Files Written -| Path | Format | When | -| ---------------------------------------------------------- | ------ | -------------------------------------------------------------------------------------------- | -| `/supabase/notebooks/` | dir | created if absent, before the first notebook is written | -| `/supabase/notebooks/.notebook-/` | JSON | scoped temporary file; linked for creation or renamed for explicit replacement, then cleaned | -| `/supabase/notebooks/.json` | JSON | only when absent during a broad pull, or replaced when its notebook id was explicitly given | -| `/telemetry.json` | JSON | whenever the handler runs — flushed on success and on failure | +| Path | Format | When | +| ---------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/notebooks/` | dir | created if absent, before the first notebook is written | +| `/supabase/notebooks/.notebook-/` | JSON | scoped temporary file; linked for creation or renamed for explicit replacement, then cleaned | +| `/supabase/notebooks/.json` | JSON | only when absent during a broad pull, or replaced when its notebook id was explicitly given | +| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | +| `/telemetry.json` | JSON | whenever the handler runs — flushed on success and on failure | Files are **removed** only when the user picks the delete answer at the reconciliation prompt: a local notebook naming no project notebook is deleted @@ -54,11 +57,13 @@ nothing else is reconciled. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------- | ------ | ------------------------------- | --------------------------------------------------------------------- | -| GET | `/v2/projects/{ref}/notebooks` | Bearer | — | `data[].id`, `data[].attributes.name`, `links.next` — broad pull only | -| GET | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | `data.{id,attributes.{name,description,favorite,content}}` | -| POST | `/v2/projects/{ref}/notebooks` | Bearer | `data.attributes` from the file | — (only for the create answer) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------- | ------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GET | `/v2/projects/{ref}/notebooks` | Bearer | — | `data[].id`, `data[].attributes.name`, `links.next` — broad pull only | +| GET | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | `data.{id,attributes.{name,description,favorite,content}}` | +| POST | `/v2/projects/{ref}/notebooks` | Bearer | `data.attributes` from the file | — (only for the create answer) | +| GET | `/v1/projects` | Bearer | — | `id`, `name`, `organization_slug`, `region` — project picker; only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | +| GET | `/v1/projects/{ref}` | Bearer | — | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | The list route is walked by following `links.next` until it is null, rather than by counting rows: a short page cannot be told from an exact multiple of the page @@ -81,12 +86,13 @@ size. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------- | ------------------------------------------------------ | -| `SUPABASE_ACCESS_TOKEN` | Management API bearer token | no (falls back to the stored login) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | -| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------- | ------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | Management API bearer token | no (falls back to the stored login) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the picker on a TTY) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/notebooks/pull/pull.command.ts b/apps/cli/src/commands/notebooks/pull/pull.command.ts index 5cac62d7de..e0b34a738c 100644 --- a/apps/cli/src/commands/notebooks/pull/pull.command.ts +++ b/apps/cli/src/commands/notebooks/pull/pull.command.ts @@ -1,9 +1,18 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withContext } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; -import { notebooksProjectRefSafeFlags } from "../notebooks.shared.ts"; +import { + notebookCreate, + notebookRead, + notebooksList, + notebooksProjectRefSafeFlags, +} from "../notebooks.shared.ts"; import { notebooksPull } from "./pull.handler.ts"; const config = { @@ -44,6 +53,22 @@ export const notebooksPullCommand = Command.make("pull", config).pipe( description: "Replace the local copy of one notebook by id", }, ]), + withPermissions( + compose( + { operations: [] }, + projectRefPicker, + withContext(notebooksList, "no notebook id argument"), + withContext( + notebookRead, + "a notebook id argument, or no id argument and a project notebook with no file in supabase/notebooks", + ), + withContext( + notebookCreate, + "no notebook id argument, local notebooks the project lacks, stdout a TTY with text output, and the create answer", + ), + linkedProjectCache, + ), + ), Command.withHandler(notebooksPullHandler), Command.provide(managementApiRuntimeLayer(["notebooks", "pull"])), ); diff --git a/apps/cli/src/commands/notebooks/pull/pull.integration.test.ts b/apps/cli/src/commands/notebooks/pull/pull.integration.test.ts index 01408caa53..b58f468424 100644 --- a/apps/cli/src/commands/notebooks/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/notebooks/pull/pull.integration.test.ts @@ -1,14 +1,21 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Option, Schema } from "effect"; +import { Effect, FileSystem, Option, Schema } from "effect"; import { useTempWorkdir } from "../../../../tests/helpers/command-mocks.ts"; import { + NOTEBOOKS_PROJECT_REF, notebookListPage, notebookResource, notebooksProject, notebooksRoute, setupNotebooks, } from "../../../../tests/helpers/notebooks.ts"; +import { + assertBlockDrift, + assertPermissionDrift, +} from "../../../../tests/helpers/permission-drift.ts"; +import { projectResponse } from "../../../../tests/helpers/permission-real-layers.ts"; import { NotebookIdError, NotebookNameConflictError } from "../notebooks.errors.ts"; +import { notebooksList } from "../notebooks.shared.ts"; import { notebooksPullHandler as notebooksPull } from "./pull.command.ts"; import type { NotebooksPullFlags } from "./pull.command.ts"; @@ -72,6 +79,7 @@ describe("notebooks pull", () => { ]); expect(out.stdoutText).toContain("Pulled 1 notebook(s)"); expect(out.stdoutText).toContain("Kept 1 existing local notebook(s) unchanged."); + assertPermissionDrift({ command: "notebooks pull", requests: http.driftRequests }); }).pipe(Effect.provide(layer)); }); @@ -101,6 +109,7 @@ describe("notebooks pull", () => { }); expect(yield* repo.exists("leftover")).toBe(true); expect(http.routeKeys).toEqual([`GET ${notebooksRoute(`/${SALES_ID}`)}`]); + assertPermissionDrift({ command: "notebooks pull", requests: http.driftRequests }); }).pipe(Effect.provide(layer)); }); @@ -171,6 +180,8 @@ describe("notebooks pull", () => { }, }, }); + expect(http.routeKeys).toEqual([`GET ${notebooksRoute()}`, `POST ${notebooksRoute()}`]); + assertPermissionDrift({ command: "notebooks pull", requests: http.driftRequests }); }).pipe(Effect.provide(layer)); }); @@ -178,7 +189,7 @@ describe("notebooks pull", () => { // divergence and resolves nothing. it.effect("leaves both sides alone when there is nobody to ask", () => { const repo = project(); - const { layer, out } = setupNotebooks({ + const { layer, out, http } = setupNotebooks({ workdir: repo.dir, format: "json", routes: { @@ -191,6 +202,8 @@ describe("notebooks pull", () => { yield* notebooksPull(flags()); expect(yield* repo.exists("gone")).toBe(true); + expect(http.routeKeys).toEqual([`GET ${notebooksRoute()}`]); + assertPermissionDrift({ command: "notebooks pull", requests: http.driftRequests }); expect(out.promptSelectCalls).toHaveLength(0); expect(out.messages).toContainEqual( expect.objectContaining({ @@ -249,6 +262,52 @@ describe("notebooks pull", () => { }).pipe(Effect.provide(layer)); }); + it.effect("picks the project, pulls its notebooks and records the linked project", () => { + const repo = project(); + const { layer, http, out } = setupNotebooks({ + workdir: repo.dir, + realLayers: true, + promptSelectResponses: [NOTEBOOKS_PROJECT_REF], + routes: { + "GET /v1/projects": { status: 200, body: [projectResponse(NOTEBOOKS_PROJECT_REF)] }, + [`GET /v1/projects/${NOTEBOOKS_PROJECT_REF}`]: { + status: 200, + body: projectResponse(NOTEBOOKS_PROJECT_REF), + }, + [`GET ${notebooksRoute()}`]: { + status: 200, + body: notebookListPage({ notebooks: [{ id: SALES_ID, name: "sales-dashboard" }] }), + }, + [`GET ${notebooksRoute(`/${SALES_ID}`)}`]: { + status: 200, + body: { data: notebookResource({ id: SALES_ID, name: "sales-dashboard" }) }, + }, + }, + }); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* notebooksPull(flags()); + + expect(http.routeKeys).toEqual([ + "GET /v1/projects", + `GET ${notebooksRoute()}`, + `GET ${notebooksRoute(`/${SALES_ID}`)}`, + `GET /v1/projects/${NOTEBOOKS_PROJECT_REF}`, + ]); + expect(out.promptSelectCalls).toHaveLength(1); + expect(yield* repo.exists("sales-dashboard")).toBe(true); + expect(out.stdoutText).toContain("Pulled 1 notebook(s)"); + expect(yield* fs.exists(`${repo.dir}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ command: "notebooks pull", requests: http.driftRequests }); + assertBlockDrift(notebooksList, { + requests: http.driftRequests.filter( + (request) => new URL(request.url).pathname === notebooksRoute(), + ), + }); + }).pipe(Effect.provide(layer)); + }); + it.effect("skips a notebook whose name cannot be a file name", () => { const repo = project(); const { layer, out } = setupNotebooks({ diff --git a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md index 8cda1d906e..887fc6168d 100644 --- a/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/notebooks/push/SIDE_EFFECTS.md @@ -8,12 +8,14 @@ notebook, and one with no match creates a new notebook. ## Files Read -| Path | Format | When | -| ------------------------------------------ | ---------- | ----------------------------------------------------------------------------------------------------------- | -| `/supabase/notebooks/` | dir | always — the file names are the notebook names; missing means empty, while any other read failure aborts | -| `/supabase/notebooks/.json` | JSON | one per notebook being pushed; all of them are read and decoded before the first call | -| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | -| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | +| Path | Format | When | +| ---------------------------------------------- | ---------- | ----------------------------------------------------------------------------------------------------------- | +| `/supabase/notebooks/` | dir | always — the file names are the notebook names; missing means empty, while any other read failure aborts | +| `/supabase/notebooks/.json` | JSON | one per notebook being pushed; all of them are read and decoded before the first call | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/linked-project.json` | JSON | existence check before the cache fill, once the project ref resolves | +| `/profile` | plain text | when neither `--profile` nor `SUPABASE_PROFILE` is set — names the profile, defaulting to `supabase` | +| `` (YAML) | YAML | when `SUPABASE_PROFILE` is a filesystem path rather than a built-in name; a read failure aborts the command | Every file is read and decoded **before** the first write, so a directory holding one unreadable notebook fails without having half-pushed the rest. @@ -25,11 +27,12 @@ kept or deleted remotely. All selected downloads are read before uploads begin. ## Files Written -| Path | Format | When | -| ---------------------------------------------------------- | ------ | --------------------------------------------------------------------------- | -| `/supabase/notebooks/.notebook-/` | JSON | scoped temporary file; atomically linked to `.json`, then cleaned | -| `/supabase/notebooks/.json` | JSON | only for a project-only notebook the user chose to write into the directory | -| `/telemetry.json` | JSON | whenever the handler runs — flushed on success and on failure | +| Path | Format | When | +| ---------------------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `/supabase/notebooks/.notebook-/` | JSON | scoped temporary file; atomically linked to `.json`, then cleaned | +| `/supabase/notebooks/.json` | JSON | only for a project-only notebook the user chose to write into the directory | +| `/supabase/.temp/linked-project.json` | JSON | after the project ref resolves, success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | +| `/telemetry.json` | JSON | whenever the handler runs — flushed on success and on failure | Nothing local is ever removed by `push`. @@ -54,13 +57,15 @@ entry are canonicalized and refused when they resolve outside the project root. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------- | ------ | -------------------------------------- | --------------------------------------------------- | -| GET | `/v2/projects/{ref}/notebooks` | Bearer | — | `data[].id`, `data[].attributes.name`, `links.next` | -| POST | `/v2/projects/{ref}/notebooks` | Bearer | `data.attributes` — name plus the file | — | -| PATCH | `/v2/projects/{ref}/notebooks/{id}` | Bearer | `data.attributes` — name plus the file | — | -| DELETE | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | — (only for the delete answer) | -| GET | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | — (only for the write-locally answer) | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ----------------------------------- | ------ | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| GET | `/v2/projects/{ref}/notebooks` | Bearer | — | `data[].id`, `data[].attributes.name`, `links.next` | +| POST | `/v2/projects/{ref}/notebooks` | Bearer | `data.attributes` — name plus the file | — | +| PATCH | `/v2/projects/{ref}/notebooks/{id}` | Bearer | `data.attributes` — name plus the file | — | +| DELETE | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | — (only for the delete answer) | +| GET | `/v2/projects/{ref}/notebooks/{id}` | Bearer | — | — (only for the write-locally answer) | +| GET | `/v1/projects` | Bearer | — | `id`, `name`, `organization_slug`, `region` — project picker; only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | +| GET | `/v1/projects/{ref}` | Bearer | — | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | `content` replaces the whole notebook body, and a cell keeps its identity by echoing back its `id` — which is why a pulled file carries cell ids and a push @@ -85,12 +90,13 @@ never mentions `favorite` keeps whatever the dashboard set. ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | --------------------------------------- | ------------------------------------------------------ | -| `SUPABASE_ACCESS_TOKEN` | Management API bearer token | no (falls back to the stored login) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | -| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ----------------------------------------- | ------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | Management API bearer token | no (falls back to the stored login) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the picker on a TTY) | +| `SUPABASE_WORKDIR` | project directory the command acts on | no (falls back to `--workdir`, then the ancestor walk) | +| `SUPABASE_HOME` | directory holding `telemetry.json` | no (falls back to `~/.supabase`) | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/notebooks/push/push.command.ts b/apps/cli/src/commands/notebooks/push/push.command.ts index 983d3f48cd..e473a861f5 100644 --- a/apps/cli/src/commands/notebooks/push/push.command.ts +++ b/apps/cli/src/commands/notebooks/push/push.command.ts @@ -1,9 +1,18 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withContext } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; -import { notebooksProjectRefSafeFlags } from "../notebooks.shared.ts"; +import { + notebookCreate, + notebookRead, + notebooksList, + notebooksProjectRefSafeFlags, +} from "../notebooks.shared.ts"; import { notebooksPush } from "./push.handler.ts"; const config = { @@ -42,6 +51,33 @@ export const notebooksPushCommand = Command.make("push", config).pipe( description: "Push a single notebook by name", }, ]), + withPermissions( + compose( + { + operations: [ + { + operationId: "v2-update-notebook", + kind: "required", + context: "a notebook file whose name a project notebook carries", + }, + { + operationId: "v2-delete-notebook", + kind: "required", + context: + "no notebook name argument, project notebooks with no local file, stdout a TTY with text output, and the delete answer", + }, + ], + }, + projectRefPicker, + notebooksList, + withContext(notebookCreate, "a notebook file whose name no project notebook carries"), + withContext( + notebookRead, + "no notebook name argument, project notebooks with no local file, stdout a TTY with text output, and the write-locally answer", + ), + linkedProjectCache, + ), + ), Command.withHandler(notebooksPushHandler), Command.provide(managementApiRuntimeLayer(["notebooks", "push"])), ); diff --git a/apps/cli/src/commands/notebooks/push/push.integration.test.ts b/apps/cli/src/commands/notebooks/push/push.integration.test.ts index 00fc6fe016..e6c227185e 100644 --- a/apps/cli/src/commands/notebooks/push/push.integration.test.ts +++ b/apps/cli/src/commands/notebooks/push/push.integration.test.ts @@ -2,17 +2,24 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, FileSystem, Option, Path, Schema } from "effect"; import { useTempWorkdir } from "../../../../tests/helpers/command-mocks.ts"; import { + NOTEBOOKS_PROJECT_REF, notebookListPage, notebookResource, notebooksProject, notebooksRoute, setupNotebooks, } from "../../../../tests/helpers/notebooks.ts"; +import { + assertBlockDrift, + assertPermissionDrift, +} from "../../../../tests/helpers/permission-drift.ts"; +import { projectResponse } from "../../../../tests/helpers/permission-real-layers.ts"; import { NotebookFileError, NotebookNameConflictError, NotebookNotFoundError, } from "../notebooks.errors.ts"; +import { notebookCreate, notebookRead, notebooksList } from "../notebooks.shared.ts"; import { notebooksPushHandler as notebooksPush } from "./push.command.ts"; import type { NotebooksPushFlags } from "./push.command.ts"; @@ -82,6 +89,16 @@ describe("notebooks push", () => { }); expect(http.requests.filter((request) => request.method === "POST")).toHaveLength(1); expect(out.stdoutText).toContain("(1 created, 1 updated)"); + expect(http.routeKeys).toEqual([ + `GET ${notebooksRoute()}`, + `POST ${notebooksRoute()}`, + `PATCH ${notebooksRoute(`/${SALES_ID}`)}`, + ]); + assertPermissionDrift({ command: "notebooks push", requests: http.driftRequests }); + assertBlockDrift(notebookCreate, { + requests: http.driftRequests.filter((request) => request.method === "POST"), + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -111,6 +128,10 @@ describe("notebooks push", () => { `GET ${notebooksRoute()}`, `PATCH ${notebooksRoute(`/${SALES_ID}`)}`, ]); + assertPermissionDrift({ + command: "notebooks push", + requests: http.driftRequests, + }); }).pipe(Effect.provide(layer)); }); @@ -176,6 +197,12 @@ describe("notebooks push", () => { expect(http.routeKeys).toContain(`DELETE ${notebooksRoute(`/${ERRORS_ID}`)}`); expect(out.stderrText).toContain(" • stale"); expect(out.stdoutText).toContain("Deleted 1 notebook(s) from the project."); + expect(http.routeKeys).toEqual([ + `GET ${notebooksRoute()}`, + `PATCH ${notebooksRoute(`/${SALES_ID}`)}`, + `DELETE ${notebooksRoute(`/${ERRORS_ID}`)}`, + ]); + assertPermissionDrift({ command: "notebooks push", requests: http.driftRequests }); }).pipe(Effect.provide(layer)); }); @@ -202,6 +229,17 @@ describe("notebooks push", () => { expect(yield* repo.exists("error-rates")).toBe(true); expect(http.routeKeys).not.toContain(`DELETE ${notebooksRoute(`/${ERRORS_ID}`)}`); + expect(http.routeKeys).toEqual([ + `GET ${notebooksRoute()}`, + `GET ${notebooksRoute(`/${ERRORS_ID}`)}`, + ]); + assertPermissionDrift({ command: "notebooks push", requests: http.driftRequests }); + assertBlockDrift(notebookRead, { + requests: http.driftRequests.filter( + (request) => new URL(request.url).pathname === notebooksRoute(`/${ERRORS_ID}`), + ), + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -224,6 +262,7 @@ describe("notebooks push", () => { expect(out.promptSelectCalls).toHaveLength(0); expect(http.routeKeys).toEqual([`GET ${notebooksRoute()}`]); + assertPermissionDrift({ command: "notebooks push", requests: http.driftRequests }); expect(out.messages).toContainEqual( expect.objectContaining({ type: "success", @@ -233,6 +272,54 @@ describe("notebooks push", () => { }).pipe(Effect.provide(layer)); }); + it.effect("picks the project, pushes its notebooks and records the linked project", () => { + const repo = project(); + const { layer, http, out } = setupNotebooks({ + command: "push", + workdir: repo.dir, + realLayers: true, + promptSelectResponses: [NOTEBOOKS_PROJECT_REF], + routes: { + "GET /v1/projects": { status: 200, body: [projectResponse(NOTEBOOKS_PROJECT_REF)] }, + [`GET /v1/projects/${NOTEBOOKS_PROJECT_REF}`]: { + status: 200, + body: projectResponse(NOTEBOOKS_PROJECT_REF), + }, + [`GET ${notebooksRoute()}`]: { + status: 200, + body: notebookListPage({ notebooks: [{ id: SALES_ID, name: "sales-dashboard" }] }), + }, + [`PATCH ${notebooksRoute(`/${SALES_ID}`)}`]: { + status: 200, + body: { data: notebookResource({ id: SALES_ID, name: "sales-dashboard" }) }, + }, + }, + }); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* repo.write("sales-dashboard", SALES_FILE); + yield* notebooksPush(flags()); + + expect(http.routeKeys).toEqual([ + "GET /v1/projects", + `GET ${notebooksRoute()}`, + `PATCH ${notebooksRoute(`/${SALES_ID}`)}`, + `GET /v1/projects/${NOTEBOOKS_PROJECT_REF}`, + ]); + expect(out.promptSelectCalls).toHaveLength(1); + expect(out.stdoutText).toContain("(0 created, 1 updated)"); + expect(yield* fs.exists(`${repo.dir}/supabase/.temp/linked-project.json`)).toBe(true); + assertPermissionDrift({ command: "notebooks push", requests: http.driftRequests }); + assertBlockDrift(notebooksList, { + requests: http.driftRequests.filter( + (request) => new URL(request.url).pathname === notebooksRoute(), + ), + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + it.effect("fails instead of treating an unreadable notebooks path as empty", () => { const repo = project(); const { layer, http } = setupNotebooks({ command: "push", workdir: repo.dir }); diff --git a/apps/cli/src/commands/orgs/create/create.command.ts b/apps/cli/src/commands/orgs/create/create.command.ts index 683f8f0e75..a07d5cd20d 100644 --- a/apps/cli/src/commands/orgs/create/create.command.ts +++ b/apps/cli/src/commands/orgs/create/create.command.ts @@ -1,6 +1,7 @@ import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -16,6 +17,7 @@ export type OrgsCreateFlags = CliCommand.Command.Config.Infer; export const orgsCreateCommand = Command.make("create", config).pipe( Command.withDescription("Create an organization for the logged-in user."), Command.withShortDescription("Create an organization"), + withPermissions({ operations: [{ operationId: "v1-create-an-organization", kind: "required" }] }), Command.withHandler((flags) => orgsCreate(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/orgs/create/create.integration.test.ts b/apps/cli/src/commands/orgs/create/create.integration.test.ts index 80ebb7b7c7..e2ef7af6cc 100644 --- a/apps/cli/src/commands/orgs/create/create.integration.test.ts +++ b/apps/cli/src/commands/orgs/create/create.integration.test.ts @@ -10,6 +10,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { orgsCreate } from "./create.handler.ts"; type CreatedOrganization = typeof V1CreateAnOrganizationOutput.Type; @@ -82,6 +83,7 @@ describe("orgs create integration", () => { expect(api.requests[0]?.method).toBe("POST"); expect(api.requests[0]?.url).toContain("/v1/organizations"); expect(api.requests[0]?.body).toEqual({ name: "Acme" }); + assertPermissionDrift({ command: "orgs create", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/orgs/list/list.command.ts b/apps/cli/src/commands/orgs/list/list.command.ts index 8c8cf207f8..b1784e15f7 100644 --- a/apps/cli/src/commands/orgs/list/list.command.ts +++ b/apps/cli/src/commands/orgs/list/list.command.ts @@ -1,6 +1,7 @@ import { Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -12,6 +13,7 @@ export type OrgsListFlags = CliCommand.Command.Config.Infer; export const orgsListCommand = Command.make("list", config).pipe( Command.withDescription("List all organizations the logged-in user belongs."), Command.withShortDescription("List all organizations"), + withPermissions({ operations: [{ operationId: "v1-list-all-organizations", kind: "required" }] }), Command.withHandler((flags) => orgsList(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/orgs/list/list.integration.test.ts b/apps/cli/src/commands/orgs/list/list.integration.test.ts index 14f36c608a..40b3e062b1 100644 --- a/apps/cli/src/commands/orgs/list/list.integration.test.ts +++ b/apps/cli/src/commands/orgs/list/list.integration.test.ts @@ -10,6 +10,7 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { orgsList } from "./list.handler.ts"; type Organizations = typeof V1ListAllOrganizationsOutput.Type; @@ -185,6 +186,7 @@ describe("orgs list integration", () => { expect(api.requests).toHaveLength(1); expect(api.requests[0]?.method).toBe("GET"); expect(api.requests[0]?.url).toContain("/v1/organizations"); + assertPermissionDrift({ command: "orgs list", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md index 9eaaaae902..469d3d5f48 100644 --- a/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/delete/SIDE_EFFECTS.md @@ -2,35 +2,40 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` - on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` - on success and failure. Not written if the gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` - on success and failure. Not written if the gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | --------------------------------------------- | ------------ | --------------------------------------------------------------- | ---------------------- | -| `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | -| `PUT` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | current config minus deleted keys (`restart_database` optional) | full JSON object | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | --------------------------------------------- | ------------ | --------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | +| `PUT` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | current config minus deleted keys (`restart_database` optional) | full JSON object | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | This command does not call a delete endpoint: it fetches the current config, removes the specified keys locally, then sends the remaining object back via `PUT`. +The remaining stored overrides are forwarded unvalidated, so a stored key this CLI version does not know is preserved. The `PUT` is sent once and never retried, since a retried `restart_database` write could restart the database twice, and its response is read as a raw JSON object so every returned key is shown. + ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -43,9 +48,8 @@ This command does not call a delete endpoint: it fetches the current config, rem | `1` | initial GET non-2xx (`PostgresConfigGetUnexpectedStatusError`) | | `1` | initial GET transport failure (`PostgresConfigGetNetworkError`) | | `1` | PUT non-2xx (`PostgresConfigDeleteUnexpectedStatusError`) | -| `1` | PUT transport failure (`PostgresConfigDeleteNetworkError`) | -| `1` | request serialization failure (`PostgresConfigDeleteSerializeError`) | -| `1` | invalid JSON response (`PostgresConfigGetUnmarshalError` / `PostgresConfigDeleteUnmarshalError`) | +| `1` | PUT transport failure (`PostgresConfigDeleteNetworkError`); the GET retries on 5xx and transport errors first, the PUT does not | +| `1` | GET body is not a JSON object (`PostgresConfigGetUnmarshalError`); PUT 2xx body is not a JSON object (`PostgresConfigDeleteUnmarshalError`) | ## Telemetry Events Fired @@ -97,10 +101,10 @@ One `result` event on success. - The `--output` flag wins over `--output-format` when both are provided. - Flags: `--config` (repeatable, config keys to delete), `--no-restart`. -- Requires `--project-ref` or a linked project (`.supabase/config.json`). +- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`), or, on an interactive terminal, the project picker. - Each config key is trimmed of whitespace before deletion. - `--no-restart` injects `restart_database = false` into the final `PUT` body. -- `linked-project.json` is written after the project ref resolves, regardless of whether the fetch or update succeeds. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is open), even when the API call fails, but only on a cache miss with a token when `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed gate writes nothing. - `telemetry.json` is written on every invocation, including failures, but only once the `--experimental` gate is open. - `postgres-config` is an experimental command: `delete` requires `--experimental` (or `SUPABASE_EXPERIMENTAL`), checked before the login check. A closed gate exits 1 diff --git a/apps/cli/src/commands/postgres-config/delete/delete.command.ts b/apps/cli/src/commands/postgres-config/delete/delete.command.ts index 54ed9cafe6..be13834d29 100644 --- a/apps/cli/src/commands/postgres-config/delete/delete.command.ts +++ b/apps/cli/src/commands/postgres-config/delete/delete.command.ts @@ -2,6 +2,11 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { postgresConfigRead, postgresConfigWrite } from "../postgres-config.shared.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/output-formats.ts"; @@ -39,6 +44,15 @@ export type PostgresConfigDeleteFlags = CliCommand.Command.Config.Infer Effect.gen(function* () { // Validate the -o value before the --experimental gate, so an invalid value is diff --git a/apps/cli/src/commands/postgres-config/delete/delete.handler.ts b/apps/cli/src/commands/postgres-config/delete/delete.handler.ts index d77e3228da..64cd69461a 100644 --- a/apps/cli/src/commands/postgres-config/delete/delete.handler.ts +++ b/apps/cli/src/commands/postgres-config/delete/delete.handler.ts @@ -31,28 +31,30 @@ export const postgresConfigDelete = Effect.fn("postgres-config.delete")(function yield* Effect.gen(function* () { const deleting = output.format === "text" ? yield* output.task("Deleting Postgres config...") : undefined; - const currentConfig = yield* fetchCurrentPostgresConfig(ref).pipe( + const preserved = yield* fetchCurrentPostgresConfig(ref).pipe( Effect.tapError(() => deleting?.fail() ?? Effect.void), ); for (const key of flags.config) { - delete currentConfig[key.trim()]; + delete preserved[key.trim()]; } - if (flags.noRestart) { - currentConfig["restart_database"] = false; - } - - const updated = yield* putPostgresConfig(ref, currentConfig, { - serializeError: (args) => new PostgresConfigDeleteSerializeError(args), - networkError: (args) => new PostgresConfigDeleteNetworkError(args), - statusError: (args) => new PostgresConfigDeleteUnexpectedStatusError(args), - parseError: (args) => new PostgresConfigDeleteUnmarshalError(args), - networkMessage: (description) => `failed to delete config overrides: ${description}`, - statusMessage: (status, body) => - `unexpected delete config overrides status ${status}: ${body}`, - parseMessage: (description) => `failed to parse delete response: ${description}`, - }).pipe(Effect.tapError(() => deleting?.fail() ?? Effect.void)); + const changes = flags.noRestart ? { restart_database: false } : {}; + + const updated = yield* putPostgresConfig( + ref, + { changes, preserved }, + { + serializeError: (args) => new PostgresConfigDeleteSerializeError(args), + networkError: PostgresConfigDeleteNetworkError, + statusError: PostgresConfigDeleteUnexpectedStatusError, + parseError: (args) => new PostgresConfigDeleteUnmarshalError(args), + networkMessage: (description) => `failed to delete config overrides: ${description}`, + statusMessage: (status, body) => + `unexpected delete config overrides status ${status}: ${body}`, + parseMessage: (description) => `failed to parse delete response: ${description}`, + }, + ).pipe(Effect.tapError(() => deleting?.fail() ?? Effect.void)); yield* deleting?.clear ?? Effect.void; yield* writePostgresConfigOutput(updated); diff --git a/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md b/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md index 4ba5db05f7..3276ce8efc 100644 --- a/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/get/SIDE_EFFECTS.md @@ -2,32 +2,35 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` - on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` - on success and failure. Not written if the gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` - on success and failure. Not written if the gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | --------------------------------------------- | ------------ | ------------ | ---------------------- | -| `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | --------------------------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -37,8 +40,8 @@ | `1` | `--experimental` not passed and `SUPABASE_EXPERIMENTAL` unset (`ExperimentalRequiredError`) - checked before ref resolution/API/telemetry | | `1` | project ref unresolved (`ProjectRefNotLinkedError` / `InvalidProjectRefError`) | | `1` | API non-2xx (`PostgresConfigGetUnexpectedStatusError`) | -| `1` | transport failure (`PostgresConfigGetNetworkError`) | -| `1` | invalid JSON response (`PostgresConfigGetUnmarshalError`) | +| `1` | transport failure (`PostgresConfigGetNetworkError`); the GET retries on 5xx and transport errors first | +| `1` | response body is not a JSON object (`PostgresConfigGetUnmarshalError`) | ## Telemetry Events Fired @@ -89,8 +92,8 @@ One `result` event on success. ## Notes - The `--output` flag wins over `--output-format` when both are provided. -- Requires `--project-ref` or a linked project (`.supabase/config.json`). -- `linked-project.json` is written after the project ref resolves, regardless of whether the fetch succeeds. +- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`), or, on an interactive terminal, the project picker. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is open), even when the API call fails, but only on a cache miss with a token when `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed gate writes nothing. - `telemetry.json` is written on every invocation, including failures, but only once the `--experimental` gate is open. - `postgres-config` is an experimental command: `get` requires `--experimental` (or `SUPABASE_EXPERIMENTAL`), checked before the login check. A closed gate exits 1 diff --git a/apps/cli/src/commands/postgres-config/get/get.command.ts b/apps/cli/src/commands/postgres-config/get/get.command.ts index 8209076dd9..5a42f60b43 100644 --- a/apps/cli/src/commands/postgres-config/get/get.command.ts +++ b/apps/cli/src/commands/postgres-config/get/get.command.ts @@ -2,6 +2,11 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { postgresConfigRead } from "../postgres-config.shared.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/output-formats.ts"; @@ -24,6 +29,9 @@ export type PostgresConfigGetFlags = CliCommand.Command.Config.Infer Effect.gen(function* () { // Validate the -o value before the --experimental gate, so an invalid value is diff --git a/apps/cli/src/commands/postgres-config/postgres-config.errors.ts b/apps/cli/src/commands/postgres-config/postgres-config.errors.ts index e193a794fa..a60144d880 100644 --- a/apps/cli/src/commands/postgres-config/postgres-config.errors.ts +++ b/apps/cli/src/commands/postgres-config/postgres-config.errors.ts @@ -44,9 +44,12 @@ export class PostgresConfigUpdateNetworkError extends Data.TaggedError( "PostgresConfigUpdateNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -68,7 +71,7 @@ export class PostgresConfigUpdateUnmarshalError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // Constructed only after a 200 status check when `parseJsonObject` fails — + // Constructed only after a 2xx status check when the body is not a JSON object -- // an API response problem, not a raw status failure. return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; } @@ -88,9 +91,12 @@ export class PostgresConfigDeleteNetworkError extends Data.TaggedError( "PostgresConfigDeleteNetworkError", )<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } @@ -112,7 +118,7 @@ export class PostgresConfigDeleteUnmarshalError extends Data.TaggedError( readonly message: string; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - // Constructed only after a 200 status check when `parseJsonObject` fails — + // Constructed only after a 2xx status check when the body is not a JSON object -- // an API response problem, not a raw status failure. return { ...actionability.apiStatus, fingerprint_suffix: "api_response" }; } diff --git a/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts b/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts index 65ee2d3026..9630c06f00 100644 --- a/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts +++ b/apps/cli/src/commands/postgres-config/postgres-config.integration.test.ts @@ -1,8 +1,13 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { makeApiClient } from "@supabase/api/effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema } from "effect"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../tests/helpers/mocks.ts"; +import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts"; import { + DEFAULT_API_URL, + VALID_TOKEN, VALID_REF, buildTestRuntime, jsonResponse, @@ -13,14 +18,96 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../tests/helpers/command-mocks.ts"; +import { + assertBlockDrift, + assertPermissionDrift, +} from "../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../tests/helpers/permission-real-layers.ts"; import { postgresConfigDelete } from "./delete/delete.handler.ts"; import { postgresConfigGet } from "./get/get.handler.ts"; +import { postgresConfigRead, postgresConfigWrite } from "./postgres-config.shared.ts"; import { postgresConfigUpdate } from "./update/update.handler.ts"; type OutputFlagValue = "env" | "pretty" | "json" | "toml" | "yaml"; +const failure = ( + exit: Exit.Exit, +): { readonly _tag: string; readonly message: string } | undefined => { + if (!Exit.isFailure(exit)) return undefined; + const error = exit.cause.reasons.find(Cause.isFailReason)?.error; + return error instanceof Error + ? { _tag: String(Reflect.get(error, "_tag")), message: error.message } + : undefined; +}; + +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-postgres-config-int-"); +const PROJECTS_URL = "https://api.supabase.com/v1/projects"; +const PROJECT_URL = `${PROJECTS_URL}/${VALID_REF}`; +const CONFIG_URL = `${PROJECT_URL}/config/database/postgres`; + +const requestLines = (api: ReturnType) => + api.requests.map(({ method, url }) => `${method} ${url}`); + +// No linked ref anywhere, so the picker runs and the real cache fills after the command. +function composedRuntime(opts: { + readonly stored: Record; + readonly updated: Record; +}) { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.url === PROJECTS_URL + ? jsonResponse(request, 200, [projectResponse(VALID_REF)]) + : request.url === PROJECT_URL + ? jsonResponse(request, 200, projectResponse(VALID_REF)) + : request.url === CONFIG_URL + ? jsonResponse(request, 200, request.method === "PUT" ? opts.updated : opts.stored) + : jsonResponse(request, 404, { message: "unrouted request" }), + ), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return { out, api, layer }; +} + +const readLinkedProjectCache = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + return yield* decodeJson( + yield* fs.readFileString(`${tempRoot.current}/supabase/.temp/linked-project.json`), + ); +}); + +// The shared mock disables client retries; this restores the production default so a test can +// prove a command opts a request out of retrying. +const withProductionRetries = ( + api: ReturnType, +): ReturnType => ({ + ...api, + layer: Layer.effect( + CommandPlatformApi, + makeApiClient({ baseUrl: DEFAULT_API_URL, accessToken: VALID_TOKEN }), + ).pipe(Layer.provide(api.httpClientLayer)), +}); + +const textResponse = (request: Parameters[0], status: number, body: string) => + HttpClientResponse.fromWeb(request, new Response(body, { status })); + function runtimeWith(opts: { readonly out: ReturnType; readonly api: ReturnType; @@ -57,6 +144,34 @@ describe("postgres-config get", () => { expect(out.stdoutText).toContain("100"); expect(api.requests[0]?.method).toBe("GET"); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/config/database/postgres`); + assertPermissionDrift({ + command: "postgres-config get", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("picks a project, reads its config and fills the linked-project cache", () => { + const { out, api, layer } = composedRuntime({ + stored: { max_connections: 100 }, + updated: {}, + }); + + return Effect.gen(function* () { + yield* postgresConfigGet({ projectRef: Option.none() }); + expect(requestLines(api)).toEqual([ + `GET ${PROJECTS_URL}`, + `GET ${CONFIG_URL}`, + `GET ${PROJECT_URL}`, + ]); + expect(out.stdoutText).toContain("max_connections"); + expect(yield* readLinkedProjectCache).toMatchObject({ ref: VALID_REF }); + assertPermissionDrift({ + command: "postgres-config get", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -206,6 +321,20 @@ describe("postgres-config get", () => { }).pipe(Effect.provide(layer)); }); + it.effect("shows stored overrides the typed update contract does not model", () => { + const out = mockOutput({ format: "json" }); + const api = mockCommandPlatformApi({ + response: { status: 200, body: { max_connections: 100, stored_only_key: "x" } }, + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* postgresConfigGet({ projectRef: Option.none() }); + const success = out.messages.find((message) => message.type === "success"); + expect(success?.data).toEqual({ max_connections: 100, stored_only_key: "x" }); + }).pipe(Effect.provide(layer)); + }); + it.effect("maps HTTP 503 to the get unexpected-status error", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi({ response: { status: 503, body: {} } }); @@ -240,57 +369,91 @@ describe("postgres-config get", () => { }); describe("postgres-config update", () => { - it.effect( - "merges current overrides, coerces values, and PUTs arbitrary keys through raw HTTP", - () => { - const out = mockOutput({ format: "text" }); - const api = mockCommandPlatformApi({ - byMethod: { - GET: { status: 200, body: { wal_keep_size: "1GB" } }, - PUT: { - status: 200, - body: { - wal_keep_size: "1GB", - max_connections: 100, - track_commit_timestamp: true, - statement_timeout: "600", - custom_key: "alpha", - restart_database: false, - }, + it.effect("merges current overrides and coerces --config values into the PUT body", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + byMethod: { + GET: { status: 200, body: { wal_keep_size: "1GB" } }, + PUT: { + status: 200, + body: { + wal_keep_size: "1GB", + max_connections: 100, + track_commit_timestamp: true, + statement_timeout: "600", + shared_buffers: "128MB", + restart_database: false, }, }, + }, + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* postgresConfigUpdate({ + projectRef: Option.none(), + config: [ + "max_connections=100", + "track_commit_timestamp=true", + "statement_timeout=600", + "shared_buffers=128MB", + ], + replaceExistingOverrides: false, + noRestart: true, }); - const layer = runtimeWith({ out, api }); + expect(out.progressEvents[0]?.message).toBe("Updating Postgres config..."); + expect(api.requests).toHaveLength(2); + expect(api.requests[1]?.method).toBe("PUT"); + expect(api.requests[1]?.body).toEqual({ + wal_keep_size: "1GB", + max_connections: 100, + track_commit_timestamp: true, + statement_timeout: "600", + shared_buffers: "128MB", + restart_database: false, + }); + expect(out.stderrText).toBe( + "- Custom Postgres Config -\n- End of Custom Postgres Config -\n", + ); + assertPermissionDrift({ + command: "postgres-config update", + activeFlags: { "replace-existing-overrides": false, "no-restart": true, config: true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); - return Effect.gen(function* () { - yield* postgresConfigUpdate({ - projectRef: Option.none(), - config: [ - "max_connections=100", - "track_commit_timestamp=true", - "statement_timeout=600", - "custom_key=alpha", - ], - replaceExistingOverrides: false, - noRestart: true, - }); - expect(out.progressEvents[0]?.message).toBe("Updating Postgres config..."); - expect(api.requests).toHaveLength(2); - expect(api.requests[1]?.method).toBe("PUT"); - expect(api.requests[1]?.body).toEqual({ - wal_keep_size: "1GB", - max_connections: 100, - track_commit_timestamp: true, - statement_timeout: "600", - custom_key: "alpha", - restart_database: false, - }); - expect(out.stderrText).toBe( - "- Custom Postgres Config -\n- End of Custom Postgres Config -\n", - ); - }).pipe(Effect.provide(layer)); - }, - ); + it.effect("picks a project, merges overrides and fills the linked-project cache", () => { + const { out, api, layer } = composedRuntime({ + stored: { wal_keep_size: "1GB" }, + updated: { wal_keep_size: "1GB", max_connections: 100 }, + }); + + return Effect.gen(function* () { + yield* postgresConfigUpdate({ + projectRef: Option.none(), + config: ["max_connections=100"], + replaceExistingOverrides: false, + noRestart: false, + }); + expect(requestLines(api)).toEqual([ + `GET ${PROJECTS_URL}`, + `GET ${CONFIG_URL}`, + `PUT ${CONFIG_URL}`, + `GET ${PROJECT_URL}`, + ]); + expect(api.requests[2]?.body).toEqual({ wal_keep_size: "1GB", max_connections: 100 }); + expect(out.stdoutText).toContain("max_connections"); + expect(yield* readLinkedProjectCache).toMatchObject({ ref: VALID_REF }); + assertPermissionDrift({ + command: "postgres-config update", + activeFlags: { "replace-existing-overrides": false }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); it.effect("skips the initial GET in replace mode", () => { const out = mockOutput({ format: "json" }); @@ -313,6 +476,18 @@ describe("postgres-config update", () => { expect(api.requests[0]?.body).toEqual({ max_connections: 100 }); const success = out.messages.find((message) => message.type === "success"); expect(success?.data).toEqual({ max_connections: 100 }); + assertPermissionDrift({ + command: "postgres-config update", + activeFlags: { "replace-existing-overrides": true }, + requests: api.requests, + }); + expect(() => + assertPermissionDrift({ + command: "postgres-config update", + activeFlags: { "replace-existing-overrides": true }, + requests: [{ method: "GET", url: CONFIG_URL }], + }), + ).toThrow(/condition doesn't hold/); }).pipe(Effect.provide(layer)); }); @@ -357,6 +532,197 @@ describe("postgres-config update", () => { }), ); + it.effect("rejects an unknown --config parameter before sending the PUT", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + byMethod: { GET: { status: 200, body: { wal_keep_size: "1GB" } } }, + }); + const cache = mockLinkedProjectCacheTracked(); + const layer = runtimeWith({ out, api, linkedProjectCache: cache.layer }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + postgresConfigUpdate({ + projectRef: Option.none(), + config: ["max_conections=100", "bogus=1", "max_connections=100"], + replaceExistingOverrides: false, + noRestart: false, + }), + ); + const error = failure(exit); + expect(error?._tag).toBe("PostgresConfigUpdateSerializeError"); + expect(error?.message).toBe( + "unknown Postgres config parameters: max_conections, bogus " + + "(if this is a newly supported parameter, upgrade the CLI)", + ); + expect(api.requests.map((request) => request.method)).toEqual(["GET"]); + expect(cache.cached).toBe(true); + assertPermissionDrift({ + command: "postgres-config update", + activeFlags: { "replace-existing-overrides": false }, + requests: api.requests, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "forwards a stored override the typed contract does not model when updating another key", + () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + byMethod: { + GET: { status: 200, body: { wal_keep_size: "1GB", stored_only_key: "x" } }, + PUT: { + status: 200, + body: { wal_keep_size: "1GB", stored_only_key: "x", max_connections: 100 }, + }, + }, + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* postgresConfigUpdate({ + projectRef: Option.none(), + config: ["max_connections=100"], + replaceExistingOverrides: false, + noRestart: false, + }); + expect(api.requests.map((request) => request.method)).toEqual(["GET", "PUT"]); + expect(api.requests[1]?.body).toEqual({ + wal_keep_size: "1GB", + stored_only_key: "x", + max_connections: 100, + }); + expect(out.stdoutText).toContain("stored_only_key"); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("lets a --config value replace the stored value of the same key", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + byMethod: { + GET: { status: 200, body: { max_connections: 50, stored_only_key: "x" } }, + PUT: { status: 200, body: { max_connections: 100, stored_only_key: "x" } }, + }, + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* postgresConfigUpdate({ + projectRef: Option.none(), + config: ["max_connections=100"], + replaceExistingOverrides: false, + noRestart: false, + }); + expect(api.requests[1]?.body).toEqual({ max_connections: 100, stored_only_key: "x" }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("reports a value the typed contract rejects with its parameter name", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi(); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + postgresConfigUpdate({ + projectRef: Option.none(), + config: ["max_connections=lots"], + replaceExistingOverrides: true, + noRestart: false, + }), + ); + expect(failure(exit)?.message).toMatch( + /^invalid Postgres config value for max_connections: /, + ); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.effect("rejects a numeric value for a string parameter locally with a unit hint", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi(); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + postgresConfigUpdate({ + projectRef: Option.none(), + config: ["work_mem=4096"], + replaceExistingOverrides: true, + noRestart: false, + }), + ); + const error = failure(exit); + expect(error?._tag).toBe("PostgresConfigUpdateSerializeError"); + expect(error?.message).toMatch(/^invalid Postgres config value for work_mem: /); + expect(error?.message).toContain("(pass the value with its unit, such as 4096kB or 4096ms)"); + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }); + + it.effect("sends a value with its unit as a string", () => { + const out = mockOutput({ format: "json" }); + const api = mockCommandPlatformApi({ + byMethod: { PUT: { status: 200, body: { work_mem: "4096kB" } } }, + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* postgresConfigUpdate({ + projectRef: Option.none(), + config: ["work_mem=4096kB"], + replaceExistingOverrides: true, + noRestart: false, + }); + expect(api.requests.map((request) => request.body)).toEqual([{ work_mem: "4096kB" }]); + }).pipe(Effect.provide(layer)); + }); + + it.effect("shows response keys the typed contract does not model", () => { + const out = mockOutput({ format: "json" }); + const api = mockCommandPlatformApi({ + byMethod: { + PUT: { status: 200, body: { max_connections: 100, server_only_key: "x" } }, + }, + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* postgresConfigUpdate({ + projectRef: Option.none(), + config: ["max_connections=100"], + replaceExistingOverrides: true, + noRestart: false, + }); + const success = out.messages.find((message) => message.type === "success"); + expect(success?.data).toEqual({ max_connections: 100, server_only_key: "x" }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("maps a non-JSON 2xx PUT body to the update unmarshal error", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => Effect.sync(() => textResponse(request, 200, "")), + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + postgresConfigUpdate({ + projectRef: Option.none(), + config: ["max_connections=100"], + replaceExistingOverrides: true, + noRestart: false, + }), + ); + const error = failure(exit); + expect(error?._tag).toBe("PostgresConfigUpdateUnmarshalError"); + expect(error?.message).toMatch(/^failed to parse update response: /); + }).pipe(Effect.provide(layer)); + }); + it.effect("fails before ref resolution on malformed config input", () => { const out = mockOutput({ format: "text" }); const api = mockCommandPlatformApi(); @@ -390,14 +756,16 @@ describe("postgres-config update", () => { }).pipe(Effect.provide(layer)); }); - it.effect("maps PUT failures to the update unexpected-status error", () => { + it.effect("surfaces a PUT 503 as the update unexpected-status error without retrying", () => { const out = mockOutput({ format: "text" }); - const api = mockCommandPlatformApi({ - byMethod: { - GET: { status: 200, body: { wal_keep_size: "1GB" } }, - PUT: { status: 503, body: {} }, - }, - }); + const api = withProductionRetries( + mockCommandPlatformApi({ + byMethod: { + GET: { status: 200, body: { wal_keep_size: "1GB" } }, + PUT: { status: 503, body: {} }, + }, + }), + ); const layer = runtimeWith({ out, api }); return Effect.gen(function* () { @@ -415,6 +783,7 @@ describe("postgres-config update", () => { expect(causeText).toContain("PostgresConfigUpdateUnexpectedStatusError"); expect(causeText).toContain("unexpected update config overrides status 503"); } + expect(api.requests.filter((request) => request.method === "PUT")).toHaveLength(1); }).pipe(Effect.provide(layer)); }); @@ -502,6 +871,84 @@ describe("postgres-config delete", () => { expect(out.stderrText).toBe( "- Custom Postgres Config -\n- End of Custom Postgres Config -\n", ); + assertPermissionDrift({ + command: "postgres-config delete", + activeFlags: { config: true, "no-restart": true }, + requests: api.requests, + exact: true, + }); + assertBlockDrift(postgresConfigRead, { requests: api.requests.slice(0, 1), exact: true }); + assertBlockDrift(postgresConfigWrite, { requests: api.requests.slice(1, 2), exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("picks a project, deletes the override and fills the linked-project cache", () => { + const { out, api, layer } = composedRuntime({ + stored: { max_connections: 100, shared_buffers: "1GB" }, + updated: { shared_buffers: "1GB" }, + }); + + return Effect.gen(function* () { + yield* postgresConfigDelete({ + projectRef: Option.none(), + config: ["max_connections"], + noRestart: false, + }); + expect(requestLines(api)).toEqual([ + `GET ${PROJECTS_URL}`, + `GET ${CONFIG_URL}`, + `PUT ${CONFIG_URL}`, + `GET ${PROJECT_URL}`, + ]); + expect(api.requests[2]?.body).toEqual({ shared_buffers: "1GB" }); + expect(out.stdoutText).toContain("shared_buffers"); + expect(yield* readLinkedProjectCache).toMatchObject({ ref: VALID_REF }); + assertPermissionDrift({ + command: "postgres-config delete", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("removes a stored override the update contract does not model", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + byMethod: { + GET: { status: 200, body: { max_connections: 100, stored_only_key: "x" } }, + PUT: { status: 200, body: { max_connections: 100 } }, + }, + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* postgresConfigDelete({ + projectRef: Option.none(), + config: ["stored_only_key"], + noRestart: false, + }); + expect(api.requests[1]?.body).toEqual({ max_connections: 100 }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("forwards a stored override the update contract does not model", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + byMethod: { + GET: { status: 200, body: { max_connections: 100, stored_only_key: "x" } }, + PUT: { status: 200, body: { stored_only_key: "x", restart_database: false } }, + }, + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + yield* postgresConfigDelete({ + projectRef: Option.none(), + config: ["max_connections"], + noRestart: true, + }); + expect(api.requests.map((request) => request.method)).toEqual(["GET", "PUT"]); + expect(api.requests[1]?.body).toEqual({ stored_only_key: "x", restart_database: false }); }).pipe(Effect.provide(layer)); }); @@ -569,12 +1016,14 @@ describe("postgres-config delete", () => { it.effect("flushes telemetry and caches the ref when delete fails after resolution", () => { const out = mockOutput({ format: "text" }); - const api = mockCommandPlatformApi({ - byMethod: { - GET: { status: 200, body: { max_connections: 100 } }, - PUT: { status: 503, body: {} }, - }, - }); + const api = withProductionRetries( + mockCommandPlatformApi({ + byMethod: { + GET: { status: 200, body: { max_connections: 100 } }, + PUT: { status: 503, body: {} }, + }, + }), + ); const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); const layer = runtimeWith({ @@ -597,11 +1046,38 @@ describe("postgres-config delete", () => { const causeText = Cause.pretty(exit.cause); expect(causeText).toContain("PostgresConfigDeleteUnexpectedStatusError"); } + expect(api.requests.filter((request) => request.method === "PUT")).toHaveLength(1); expect(telemetry.flushed).toBe(true); expect(cache.cached).toBe(true); }).pipe(Effect.provide(layer)); }); + it.effect("maps a non-JSON 2xx PUT body to the delete unmarshal error", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => + request.method === "GET" + ? jsonResponse(request, 200, { max_connections: 100 }) + : textResponse(request, 200, ""), + ), + }); + const layer = runtimeWith({ out, api }); + + return Effect.gen(function* () { + const exit = yield* Effect.exit( + postgresConfigDelete({ + projectRef: Option.none(), + config: ["max_connections"], + noRestart: false, + }), + ); + const error = failure(exit); + expect(error?._tag).toBe("PostgresConfigDeleteUnmarshalError"); + expect(error?.message).toMatch(/^failed to parse delete response: /); + }).pipe(Effect.provide(layer)); + }); + it.effect("surfaces the shared get error when the initial fetch fails", () => { const out = mockOutput({ format: "json" }); const api = mockCommandPlatformApi({ @@ -622,8 +1098,11 @@ describe("postgres-config delete", () => { const causeText = Cause.pretty(exit.cause); expect(causeText).toContain("PostgresConfigGetUnexpectedStatusError"); } - expect(api.requests).toHaveLength(1); - expect(api.requests[0]?.method).toBe("GET"); + expect(api.requests.map((request) => request.method)).toEqual(["GET"]); + assertPermissionDrift({ + command: "postgres-config delete", + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/postgres-config/postgres-config.shared.ts b/apps/cli/src/commands/postgres-config/postgres-config.shared.ts index 8d8edc4a51..0bc2b90610 100644 --- a/apps/cli/src/commands/postgres-config/postgres-config.shared.ts +++ b/apps/cli/src/commands/postgres-config/postgres-config.shared.ts @@ -1,22 +1,19 @@ -import { Effect, Option, Schema } from "effect"; -import * as HttpClient from "effect/http/HttpClient"; -import type * as HttpClientError from "effect/http/HttpClientError"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import { operationDefinitions } from "@supabase/api/effect"; +import { Effect, Option, Schema, SchemaIssue } from "effect"; -import { CommandSettings } from "../../config/command-settings.service.ts"; +import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts"; +import { block } from "../../command-internal/command-permissions/model.ts"; import { OutputFlag } from "../../command-internal/global-flags.ts"; import { Output } from "../../shared/output/output.service.ts"; import { renderGlamourTable } from "../../output/glamour-table.ts"; -import { - encodeEnv, - encodeSortedJson, - encodeSortedJsonBody, - encodeYaml, -} from "../../command-internal/output.encoders.ts"; +import { encodeEnv, encodeSortedJson, encodeYaml } from "../../command-internal/output.encoders.ts"; import { formatGeneralFloat } from "../../command-internal/format-float.ts"; -import { sanitizeErrorBody } from "../../command-internal/http-errors.ts"; -import { requestWithAuth } from "../../command-internal/raw-http.ts"; -import { resolveAccessToken } from "../../command-internal/resolve-token.ts"; +import { + mapHttpError, + sanitizeErrorBody, + type NetworkErrorFactory, + type StatusErrorFactory, +} from "../../command-internal/http-errors.ts"; import { PostgresConfigGetNetworkError, PostgresConfigGetUnexpectedStatusError, @@ -105,88 +102,147 @@ export function normalizeTimeoutConfig(config: PostgresConfigMap): void { } } -function mapTransportMessage( - cause: HttpClientError.HttpClientError, - message: (description: string) => string, - wrap: (args: { readonly message: string }) => E, -): E { - return wrap({ message: message(cause.reason.description ?? cause.reason._tag) }); -} - const decodeJsonObject = Schema.decodeEffect( Schema.fromJsonString(Schema.Record(Schema.String, Schema.Unknown)), ); -function parseJsonObject( - rawBody: string, - errorMessage: (description: string) => string, - wrap: (args: { readonly message: string }) => E, -): Effect.Effect { - return decodeJsonObject(rawBody).pipe( - Effect.mapError((error) => wrap({ message: errorMessage(error.message) })), - ); -} +const getStatusMessage = (status: number, body: string) => + `unexpected config overrides status ${status}: ${body}`; +const mapGetError = mapHttpError({ + networkError: PostgresConfigGetNetworkError, + statusError: PostgresConfigGetUnexpectedStatusError, + networkMessage: (description) => `failed to retrieve Postgres config overrides: ${description}`, + statusMessage: getStatusMessage, +}); + +/** `fetchCurrentPostgresConfig`: the project's stored Postgres config overrides. */ +export const postgresConfigRead = block("postgres-config-read", [ + { operationId: "v1-get-postgres-config", kind: "required" }, +]); + +/** `putPostgresConfig`: replaces the project's Postgres config overrides. */ +export const postgresConfigWrite = block("postgres-config-write", [ + { operationId: "v1-update-postgres-config", kind: "required" }, +]); + +/** + * Reads the stored overrides as the platform returned them. Read-modify-write callers must see + * every stored key, so the response is not decoded against the typed output schema. + */ export const fetchCurrentPostgresConfig = Effect.fn("postgres-config.fetch-current")(function* ( ref: string, ) { - const httpClient = yield* HttpClient.HttpClient; - const cliSettings = yield* CommandSettings; - const tokenOpt = yield* resolveAccessToken; - - const request = requestWithAuth( - HttpClientRequest.get(`${cliSettings.apiUrl}/v1/projects/${ref}/config/database/postgres`), - tokenOpt, - cliSettings.userAgent, - ); + const api = yield* CommandPlatformApi; - const response = yield* httpClient.execute(request).pipe( - Effect.mapError((cause) => - mapTransportMessage( - cause, - (description) => `failed to retrieve Postgres config overrides: ${description}`, - (args) => new PostgresConfigGetNetworkError(args), - ), - ), - ); + const response = yield* api + .executeRaw(operationDefinitions.v1GetPostgresConfig, { ref }) + .pipe(Effect.catch(mapGetError)); + const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); if (response.status !== 200) { - const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); const body = sanitizeErrorBody(rawBody); return yield* new PostgresConfigGetUnexpectedStatusError({ status: response.status, body, - message: `unexpected config overrides status ${response.status}: ${body}`, + message: getStatusMessage(response.status, body), }); } - const rawBody = yield* response.text; - return yield* parseJsonObject( - rawBody, - (description) => `failed to parse response body: ${description}`, - (args) => new PostgresConfigGetUnmarshalError(args), + const stored = yield* decodeJsonObject(rawBody).pipe( + Effect.mapError( + (error) => + new PostgresConfigGetUnmarshalError({ + message: `failed to parse response body: ${error.message}`, + }), + ), ); + return { ...stored } satisfies PostgresConfigMap; }); +const updateOperation = operationDefinitions.v1UpdatePostgresConfig; +const decodeUpdateInput = Schema.decodeUnknownEffect(updateOperation.inputSchema); + +interface ConfigProblems { + readonly unknownKeys: Array; + readonly invalidValues: Array; +} + +function collectConfigProblems( + issue: SchemaIssue.Issue, + path: ReadonlyArray, + config: PostgresConfigMap, + problems: ConfigProblems, +): void { + switch (issue._tag) { + case "Composite": + for (const nested of issue.issues) collectConfigProblems(nested, path, config, problems); + return; + case "Pointer": + collectConfigProblems(issue.issue, [...path, ...issue.path], config, problems); + return; + case "UnexpectedKey": + problems.unknownKeys.push(path.map(String).join(".")); + return; + default: { + const key = path.map(String).join("."); + const given = config[key]; + const hint = + issue._tag === "InvalidType" && typeof given === "number" + ? ` (pass the value with its unit, such as ${given}kB or ${given}ms)` + : ""; + problems.invalidValues.push(`${key}: ${SchemaIssue.makeFormatterDefault()(issue)}${hint}`); + } + } +} + +function describeConfigProblems(issue: SchemaIssue.Issue, config: PostgresConfigMap) { + const problems: ConfigProblems = { unknownKeys: [], invalidValues: [] }; + collectConfigProblems(issue, [], config, problems); + const messages: Array = []; + const { unknownKeys } = problems; + if (unknownKeys.length > 0) { + const plural = unknownKeys.length > 1; + messages.push( + `unknown Postgres config ${plural ? "parameters" : "parameter"}: ${unknownKeys.join(", ")} ` + + `(if this is a newly supported parameter, upgrade the CLI)`, + ); + } + for (const invalid of problems.invalidValues) { + messages.push(`invalid Postgres config value for ${invalid}`); + } + return messages.join("; "); +} + /** * Per-operation error wiring for {@link putPostgresConfig}. Both `update` and `delete` issue - * the same PUT but tag failures with their own error types and message verbs (mirroring - * `mapHttpError`), keeping each call site's error channel precise. + * the same PUT but tag failures with their own error types and message verbs. */ export interface PutPostgresConfigErrors { readonly serializeError: (args: { readonly message: string }) => SerErr; - readonly networkError: (args: { readonly message: string }) => NetErr; - readonly statusError: (args: { - readonly status: number; - readonly body: string; - readonly message: string; - }) => StatErr; + readonly networkError: NetworkErrorFactory; + readonly statusError: StatusErrorFactory; readonly parseError: (args: { readonly message: string }) => UnmErr; readonly networkMessage: (description: string) => string; readonly statusMessage: (status: number, body: string) => string; readonly parseMessage: (description: string) => string; } +/** + * The overrides to PUT, split by provenance. `changes` is what the user asked for; + * `preserved` is the stored overrides to resend untouched, since the PUT replaces the full map. + */ +export interface PostgresConfigWrite { + readonly changes: PostgresConfigMap; + readonly preserved: PostgresConfigMap; +} + +/** + * PUTs the full overrides map. `changes` is validated against the platform's strict request + * schema, so a bad key or value fails naming the key instead of as an opaque 400; `preserved` is + * forwarded unvalidated so a stored override this CLI's schema does not know is never dropped. + * The PUT is never retried: with `restart_database` a resent write restarts the database twice. + */ export const putPostgresConfig = Effect.fn("postgres-config.put")(function* < SerErr, NetErr, @@ -194,44 +250,32 @@ export const putPostgresConfig = Effect.fn("postgres-config.put")(function* < UnmErr, >( ref: string, - config: PostgresConfigMap, + { changes, preserved }: PostgresConfigWrite, errors: PutPostgresConfigErrors, ) { - const httpClient = yield* HttpClient.HttpClient; - const cliSettings = yield* CommandSettings; - const tokenOpt = yield* resolveAccessToken; - - // Uses raw HTTP instead of the generated input schema, since --config accepts arbitrary - // keys the typed client's OpenAPI-modeled fields don't cover. - const encodedBody = yield* Effect.try({ - try: () => encodeSortedJsonBody(config), - catch: (cause) => - errors.serializeError({ - message: `failed to serialize config overrides: ${String(cause)}`, - }), - }); + const api = yield* CommandPlatformApi; - const request = requestWithAuth( - HttpClientRequest.put(`${cliSettings.apiUrl}/v1/projects/${ref}/config/database/postgres`).pipe( - HttpClientRequest.bodyText(encodedBody, "application/json"), + const input = yield* decodeUpdateInput( + { ...changes, ref }, + { onExcessProperty: "error", errors: "all" }, + ).pipe( + Effect.mapError((error) => + errors.serializeError({ message: describeConfigProblems(error.issue, changes) }), ), - tokenOpt, - cliSettings.userAgent, + ); + const extraBody = Object.fromEntries( + Object.entries(preserved).filter(([key]) => !Object.hasOwn(changes, key)), ); - const response = yield* httpClient - .execute(request) - .pipe( - Effect.mapError((cause) => - mapTransportMessage(cause, errors.networkMessage, errors.networkError), - ), - ); + const response = yield* api + .executeRaw(updateOperation, input, { retry: { maxRetries: 0 }, extraBody }) + .pipe(Effect.catch(mapHttpError(errors))); - if (response.status !== 200) { - const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); + if (response.status < 200 || response.status >= 300) { const body = sanitizeErrorBody(rawBody); return yield* Effect.fail( - errors.statusError({ + new errors.statusError({ status: response.status, body, message: errors.statusMessage(response.status, body), @@ -239,8 +283,10 @@ export const putPostgresConfig = Effect.fn("postgres-config.put")(function* < ); } - const rawBody = yield* response.text; - return yield* parseJsonObject(rawBody, errors.parseMessage, errors.parseError); + const stored = yield* decodeJsonObject(rawBody).pipe( + Effect.mapError((error) => errors.parseError({ message: errors.parseMessage(error.message) })), + ); + return { ...stored } satisfies PostgresConfigMap; }); export const writePostgresConfigOutput = Effect.fn("postgres-config.write-output")(function* ( diff --git a/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md b/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md index a8033e37e7..e4090824f4 100644 --- a/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/postgres-config/update/SIDE_EFFECTS.md @@ -2,35 +2,40 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` - on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` - on success and failure. Not written if the gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves (only if every `--config` value is a `key=value` pair), on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` - on success and failure. Not written if the gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | --------------------------------------------- | ------------ | -------------------------------------------------------------- | ---------------------- | -| `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | -| `PUT` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | full config object (conditional GET merge unless replace mode) | full JSON object | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | --------------------------------------------- | ------------ | -------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | none | full JSON object | +| `PUT` | `/v1/projects/{ref}/config/database/postgres` | Bearer token | full config object (conditional GET merge unless replace mode) | full JSON object | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | The initial `GET` is skipped when `--replace-existing-overrides` is set. Otherwise the command fetches current overrides first, merges the new values locally, then sends the final merged object back via `PUT`. +The keys passed with `--config` (and `restart_database`) are validated locally against the API's request schema before the `PUT`, which mirrors the platform's strict validation: unknown keys and wrongly typed values fail locally and no `PUT` is sent. Stored overrides are forwarded unvalidated, so a stored key this CLI version does not know is preserved. An unknown-key failure names the key and suggests upgrading the CLI; a number given for a size or duration parameter (`work_mem=4096`) suggests passing the value with its unit (`work_mem=4096kB`). The `PUT` is sent once and never retried, since a retried `restart_database` write could restart the database twice, and its response is read as a raw JSON object so every returned key is shown. + ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` -> prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring -> `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -44,9 +49,9 @@ The initial `GET` is skipped when `--replace-existing-overrides` is set. Otherwi | `1` | initial GET non-2xx (`PostgresConfigGetUnexpectedStatusError`) | | `1` | initial GET transport failure (`PostgresConfigGetNetworkError`) | | `1` | PUT non-2xx (`PostgresConfigUpdateUnexpectedStatusError`) | -| `1` | PUT transport failure (`PostgresConfigUpdateNetworkError`) | -| `1` | request serialization failure (`PostgresConfigUpdateSerializeError`) | -| `1` | invalid JSON response (`PostgresConfigGetUnmarshalError` / `PostgresConfigUpdateUnmarshalError`) | +| `1` | PUT transport failure (`PostgresConfigUpdateNetworkError`); the GET retries on 5xx and transport errors first, the PUT does not | +| `1` | rejected before the PUT: unknown or wrongly typed config parameter (`PostgresConfigUpdateSerializeError`, e.g. `unknown Postgres config parameter: foo (...)`, `invalid Postgres config value for work_mem: ...`) | +| `1` | GET body is not a JSON object (`PostgresConfigGetUnmarshalError`); PUT 2xx body is not a JSON object (`PostgresConfigUpdateUnmarshalError`) | ## Telemetry Events Fired @@ -97,12 +102,12 @@ One `result` event on success. ## Notes - The `--output` flag wins over `--output-format` when both are provided. -- Flags: `--config` (repeatable, parsed by splitting on the first `=`), `--replace-existing-overrides`, `--no-restart`. -- Requires `--project-ref` or a linked project (`.supabase/config.json`). +- Flags: `--config` (repeatable; each value must contain exactly one `=`, otherwise `PostgresConfigInvalidConfigValueError`), `--replace-existing-overrides`, `--no-restart`. +- Requires `--project-ref`, `SUPABASE_PROJECT_ID`, a linked project (`supabase/.temp/project-ref`), or, on an interactive terminal, the project picker. - Integer-like values are coerced to integers, boolean-like values are coerced to booleans, and everything else stays stringly typed before the final JSON body is serialized. - Keys ending in `_timeout` are always stringified before the `PUT`. - `--no-restart` injects `restart_database = false` into the final request body. -- `linked-project.json` is written after the project ref resolves, regardless of whether the fetch or update succeeds. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is open), even when the API call fails, but only on a cache miss with a token when `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed gate writes nothing. - `telemetry.json` is written on every invocation, including failures, but only once the `--experimental` gate is open. - `postgres-config` is an experimental command: `update` requires `--experimental` (or `SUPABASE_EXPERIMENTAL`), checked before the login check. A closed gate exits 1 diff --git a/apps/cli/src/commands/postgres-config/update/update.command.ts b/apps/cli/src/commands/postgres-config/update/update.command.ts index 9d3b3979eb..c462cb088a 100644 --- a/apps/cli/src/commands/postgres-config/update/update.command.ts +++ b/apps/cli/src/commands/postgres-config/update/update.command.ts @@ -2,6 +2,11 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withCondition } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { postgresConfigRead, postgresConfigWrite } from "../postgres-config.shared.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/output-formats.ts"; @@ -45,6 +50,15 @@ export type PostgresConfigUpdateFlags = CliCommand.Command.Config.Infer Effect.gen(function* () { // Validate the -o value before the --experimental gate, so an invalid value is diff --git a/apps/cli/src/commands/postgres-config/update/update.handler.ts b/apps/cli/src/commands/postgres-config/update/update.handler.ts index db960d2949..b5d81c62c0 100644 --- a/apps/cli/src/commands/postgres-config/update/update.handler.ts +++ b/apps/cli/src/commands/postgres-config/update/update.handler.ts @@ -44,32 +44,38 @@ export const postgresConfigUpdate = Effect.fn("postgres-config.update")(function const updating = output.format === "text" ? yield* output.task("Updating Postgres config...") : undefined; - const finalOverrides = flags.replaceExistingOverrides + const preserved = flags.replaceExistingOverrides ? {} : yield* fetchCurrentPostgresConfig(ref).pipe( Effect.tapError(() => updating?.fail() ?? Effect.void), ); + const changes: Record = {}; for (const [key, value] of Object.entries(nextOverrides)) { - finalOverrides[key] = parseConfigValue(value); + changes[key] = parseConfigValue(value); } if (flags.noRestart) { - finalOverrides["restart_database"] = false; + changes["restart_database"] = false; } - normalizeTimeoutConfig(finalOverrides); + normalizeTimeoutConfig(changes); + normalizeTimeoutConfig(preserved); - const updated = yield* putPostgresConfig(ref, finalOverrides, { - serializeError: (args) => new PostgresConfigUpdateSerializeError(args), - networkError: (args) => new PostgresConfigUpdateNetworkError(args), - statusError: (args) => new PostgresConfigUpdateUnexpectedStatusError(args), - parseError: (args) => new PostgresConfigUpdateUnmarshalError(args), - networkMessage: (description) => `failed to update config overrides: ${description}`, - statusMessage: (status, body) => - `unexpected update config overrides status ${status}: ${body}`, - parseMessage: (description) => `failed to parse update response: ${description}`, - }).pipe(Effect.tapError(() => updating?.fail() ?? Effect.void)); + const updated = yield* putPostgresConfig( + ref, + { changes, preserved }, + { + serializeError: (args) => new PostgresConfigUpdateSerializeError(args), + networkError: PostgresConfigUpdateNetworkError, + statusError: PostgresConfigUpdateUnexpectedStatusError, + parseError: (args) => new PostgresConfigUpdateUnmarshalError(args), + networkMessage: (description) => `failed to update config overrides: ${description}`, + statusMessage: (status, body) => + `unexpected update config overrides status ${status}: ${body}`, + parseMessage: (description) => `failed to parse update response: ${description}`, + }, + ).pipe(Effect.tapError(() => updating?.fail() ?? Effect.void)); yield* updating?.clear ?? Effect.void; yield* writePostgresConfigOutput(updated); diff --git a/apps/cli/src/commands/projects/SIDE_EFFECTS.md b/apps/cli/src/commands/projects/SIDE_EFFECTS.md index f2c3321564..ada4ae5a29 100644 --- a/apps/cli/src/commands/projects/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/projects/SIDE_EFFECTS.md @@ -27,21 +27,22 @@ subcommand's own `SIDE_EFFECTS.md`. ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ----------------------------- | ------------ | ---------------------------------------------------------------------------- | ---------------------------------------------------------------------------- | -| `GET` | `/v1/projects` | Bearer token | — | `[{id, ref, organization_slug, name, region, created_at, status, database}]` | -| `POST` | `/v1/projects` | Bearer token | `{name, organization_slug, db_pass, region?, desired_instance_size?}` (JSON) | `{id, ref, organization_slug, name, region, created_at, status}` | -| `DELETE` | `/v1/projects/{ref}` | Bearer token | — | `{id, ref, name}`; `404` → does-not-exist | -| `GET` | `/v1/projects/{ref}/api-keys` | Bearer token | — | `[{name, api_key?}]` | -| `GET` | `/v1/organizations` | Bearer token | — | `[{id, slug, name}]` — `create` interactive org prompt only | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ----------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------------------------------- | +| `GET` | `/v1/projects` | Bearer token | — | `[{id, ref, organization_slug, name, region, created_at, status, database}]` | +| `POST` | `/v1/projects` | Bearer token | `{name, organization_slug, db_pass, region?, desired_instance_size?, high_availability?, release_channel?, postgres_engine?}` (JSON) | `{id, ref, organization_slug, name, region, created_at, status}` | +| `DELETE` | `/v1/projects/{ref}` | Bearer token | — | `{id, ref, name}`; `404` → does-not-exist | +| `GET` | `/v1/projects/{ref}/api-keys` | Bearer token | — | `[{name, api_key?}]` | +| `GET` | `/v1/projects/{ref}` | Bearer token | — | linked-project cache fill, best-effort (`Effect.ensuring`) | +| `GET` | `/v1/organizations` | Bearer token | — | `[{id, slug, name}]` — `create` interactive org prompt only | ## 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_REF` | linked project ref (via the config layer) | no (used by `list` marker / `api-keys` ref / `delete` unlink) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROJECT_ID` | linked project ref (via the config layer) | no (used by the `list` marker and the `api-keys` ref; `delete` ignores it and reads only `supabase/.temp/project-ref`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | > `DB_PASSWORD` is **not** consumed by `projects create`. @@ -78,10 +79,10 @@ subcommand's own `SIDE_EFFECTS.md`. - **Terminal color:** refs / project names / dashboard URLs are emitted as plain text, with no ANSI color, whether or not stdout/stderr is a TTY. -- **`create` linked cache:** the new project ref is cached on success; - `delete` also caches the resolved ref, even though the project is gone — the - cache is a telemetry-group record, separate from the `supabase/.temp` link - removed during unlink. +- **`create` linked cache:** the new project ref is cached on success. `delete` + first removes `supabase/.temp` when the deleted ref is the linked one, then makes + a best-effort cache fill (`supabase/.temp/linked-project.json`); the project + lookup it needs returns 404 for a deleted project, so nothing is written. - **`create` non-interactive errors:** consolidates what used to be per-flag "required flag(s) … not set" errors into a single `ProjectsCreateMissingArgError` that lists every missing item at once (a deliberate UX improvement over the diff --git a/apps/cli/src/commands/projects/api-keys/api-keys.command.ts b/apps/cli/src/commands/projects/api-keys/api-keys.command.ts index 30d1244354..32b38c8748 100644 --- a/apps/cli/src/commands/projects/api-keys/api-keys.command.ts +++ b/apps/cli/src/commands/projects/api-keys/api-keys.command.ts @@ -1,5 +1,10 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withCondition } from "../../../command-internal/command-permissions/model.ts"; +import { projectApiKeys } from "../../../command-internal/get-api-keys.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -30,6 +35,23 @@ export const projectsApiKeysCommand = Command.make("api-keys", config).pipe( description: "List API keys with the secret keys revealed in full", }, ]), + withPermissions( + compose( + { + operations: [ + { + operationId: "v1-get-project-api-keys", + kind: "required", + when: [{ flag: "reveal", is: true }], + request: { query: { reveal: "true" } }, + }, + ], + }, + withCondition(projectApiKeys, { flag: "reveal", is: false }), + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => projectsApiKeys(flags).pipe( // `reveal` is intentionally not in `safeFlags`: it is a boolean flag, and diff --git a/apps/cli/src/commands/projects/api-keys/api-keys.integration.test.ts b/apps/cli/src/commands/projects/api-keys/api-keys.integration.test.ts index 66886351dd..026283e993 100644 --- a/apps/cli/src/commands/projects/api-keys/api-keys.integration.test.ts +++ b/apps/cli/src/commands/projects/api-keys/api-keys.integration.test.ts @@ -1,15 +1,18 @@ -import type { V1GetProjectApiKeysOutput } from "@supabase/api/effect"; +import type { V1GetProjectApiKeysOutput, V1ListAllProjectsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockCommandPlatformApi, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { projectsApiKeys } from "./api-keys.handler.ts"; type ApiKeys = typeof V1GetProjectApiKeysOutput.Type; @@ -25,6 +28,26 @@ const REVEALED_KEYS: ApiKeys = [ ]; const FLAG_REF = "qrstuvwxyzabcdefghij"; +const API = "https://api.supabase.com"; + +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const PROJECT: (typeof V1ListAllProjectsOutput.Type)[number] = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "acme", + name: "alpha", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}; const tempRoot = useTempWorkdir("supabase-projects-apikeys-int-"); @@ -89,6 +112,12 @@ describe("projects api-keys integration", () => { return Effect.gen(function* () { yield* projectsApiKeys({ projectRef: Option.none(), reveal: false }); expect(api.requests[0]?.urlWithParams).not.toContain("reveal"); + assertPermissionDrift({ + command: "projects api-keys", + activeFlags: { reveal: false }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -97,6 +126,12 @@ describe("projects api-keys integration", () => { return Effect.gen(function* () { yield* projectsApiKeys({ projectRef: Option.none(), reveal: true }); expect(api.requests[0]?.urlWithParams).toContain("reveal=true"); + assertPermissionDrift({ + command: "projects api-keys", + activeFlags: { reveal: true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -227,4 +262,57 @@ describe("projects api-keys integration", () => { } }).pipe(Effect.provide(layer)); }); + + it.effect("lists the keys of the project chosen in the picker and fills the real cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "GET" && request.url === `${API}/v1/projects`) { + return jsonResponse(request, 200, [PROJECT]); + } + if (request.method === "GET" && request.url === `${API}/v1/projects/${VALID_REF}`) { + return jsonResponse(request, 200, PROJECT); + } + if (request.url === `${API}/v1/projects/${VALID_REF}/api-keys`) { + return jsonResponse(request, 200, SAMPLE_KEYS); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* projectsApiKeys({ projectRef: Option.none(), reveal: false }); + + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${API}/v1/projects`, + `GET ${API}/v1/projects/${VALID_REF}/api-keys`, + `GET ${API}/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain("anon-secret"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "projects api-keys", + activeFlags: { reveal: false }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/cli/src/commands/projects/create/create.command.ts b/apps/cli/src/commands/projects/create/create.command.ts index 7ca2da77c0..73e5e60e8e 100644 --- a/apps/cli/src/commands/projects/create/create.command.ts +++ b/apps/cli/src/commands/projects/create/create.command.ts @@ -1,6 +1,14 @@ import { V1CreateAProjectInput } from "@supabase/api/effect"; import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { + compose, + withCondition, + withContext, +} from "../../../command-internal/command-permissions/model.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { organizationPicker } from "../../../command-internal/organization-picker.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -102,6 +110,13 @@ const config = { Flag.withHidden, ), }; +// `--interactive` is read as `interactive ?? true`: absent and `true` can prompt, `false` cannot; +// an optional boolean can't be tested with `is`, so the present entry names the value in its context. +const interactivePicker = withContext( + organizationPicker, + "an interactive terminal (stdin TTY and text output)", +); + export type ProjectsCreateFlags = CliCommand.Command.Config.Infer; export const projectsCreateCommand = Command.make("create", config).pipe( @@ -114,6 +129,28 @@ export const projectsCreateCommand = Command.make("create", config).pipe( description: "Create a new project", }, ]), + withPermissions( + compose( + { + operations: [{ operationId: "v1-create-a-project", kind: "required" }], + noApiEffectFlags: [ + "db-password", + "region", + "size", + "high-availability", + "release-channel", + "postgres-engine", + "plan", + ], + }, + withCondition(interactivePicker, { flag: "interactive", present: false }), + withCondition(withContext(interactivePicker, "--interactive is true, not false"), { + flag: "interactive", + present: true, + }), + linkedProjectCache, + ), + ), Command.withHandler((flags) => projectsCreate(flags).pipe( // `high-availability` is omitted from `safeFlags` since boolean flags are always logged diff --git a/apps/cli/src/commands/projects/create/create.integration.test.ts b/apps/cli/src/commands/projects/create/create.integration.test.ts index a5e7047b88..86af194602 100644 --- a/apps/cli/src/commands/projects/create/create.integration.test.ts +++ b/apps/cli/src/commands/projects/create/create.integration.test.ts @@ -1,6 +1,6 @@ import type { OrganizationResponseV1_Output, V1CreateAProjectOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option, Schema } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema } from "effect"; import { CliError, CliOutput, Command } from "effect/cli"; import { mockOutput, mockTelemetryRuntime, mockTty } from "../../../../tests/helpers/mocks.ts"; @@ -11,6 +11,7 @@ import { type ApiResponse, type HttpMethod, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, @@ -18,6 +19,12 @@ import { useTempWorkdir, withConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; +import { + assertBlockDrift, + assertPermissionDrift, +} from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; +import { organizationPicker } from "../../../command-internal/organization-picker.ts"; import { projectsCreateCommand, type ProjectsCreateFlags } from "./create.command.ts"; import { projectsCreate } from "./create.handler.ts"; @@ -52,6 +59,8 @@ const BASE_FLAGS: ProjectsCreateFlags = { const tempRoot = useTempWorkdir("supabase-projects-create-int-"); const jsonText = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); +const API = "https://api.supabase.com"; interface SetupOpts { readonly format?: "text" | "json" | "stream-json"; @@ -130,6 +139,15 @@ describe("projects create integration", () => { ); expect(out.stdoutText).toContain("REFERENCE ID"); expect(out.stdoutText).toContain("East US (North Virginia)"); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `POST ${API}/v1/projects`, + ]); + assertPermissionDrift({ + command: "projects create", + activeFlags: { "org-id": true, "db-password": true, region: true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -369,9 +387,44 @@ describe("projects create integration", () => { }); expect(out.stderrText).toContain("Selected org-id:"); expect(out.stderrText).toContain("Selected region:"); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${API}/v1/organizations`, + `POST ${API}/v1/projects`, + ]); + assertPermissionDrift({ command: "projects create", requests: api.requests }); + assertBlockDrift(organizationPicker, { requests: api.requests.slice(0, 1), exact: true }); }).pipe(Effect.provide(layer)); }); + it.effect( + "lists the organizations when --interactive is passed on a TTY without --org-id", + () => { + const { layer, api } = setup({ + stdinIsTty: true, + promptSelectResponses: ["org-abc"], + }); + return Effect.gen(function* () { + yield* projectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + interactive: Option.some(true), + }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${API}/v1/organizations`, + `POST ${API}/v1/projects`, + ]); + assertPermissionDrift({ + command: "projects create", + activeFlags: { interactive: true, "db-password": true, region: true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("fails with ProjectsCreateNameEmptyError when the name prompt is blank", () => { const { layer } = setup({ stdinIsTty: true, promptTextResponses: [""] }); return Effect.gen(function* () { @@ -384,7 +437,7 @@ describe("projects create integration", () => { }); it.effect("fails when the interactive organization list request errors", () => { - const { layer } = setup({ + const { layer, api } = setup({ stdinIsTty: true, byMethod: { GET: { status: 500, body: {} }, POST: { status: 201, body: CREATED } }, }); @@ -396,6 +449,10 @@ describe("projects create integration", () => { if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("ProjectsOrgsListUnexpectedStatusError"); } + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${API}/v1/organizations`, + ]); + assertPermissionDrift({ command: "projects create", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -533,13 +590,10 @@ describe("projects create integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("tolerates a 201 response with a placeholder/short ref (lenient parse)", () => { - // The typed client validates refs to be 20+ chars; `executeRaw` bypasses that to render - // placeholders verbatim. + it.effect("passes through created-project fields the schema does not declare", () => { const { layer, out } = setup({ - byMethod: { - POST: { status: 201, body: { ...CREATED, id: "__PROJECT_REF__", ref: "__PROJECT_REF__" } }, - }, + outputFlag: "json", + byMethod: { POST: { status: 201, body: { ...CREATED, undeclared_field: "kept" } } }, }); return Effect.gen(function* () { yield* projectsCreate({ @@ -549,9 +603,7 @@ describe("projects create integration", () => { dbPassword: Option.some("s3cret-pass"), region: Option.some("us-east-1"), }); - expect(out.stderrText).toContain( - "Created a new project at https://supabase.com/dashboard/project/__PROJECT_REF__", - ); + expect(out.stdoutText).toContain('"undeclared_field": "kept"'); }).pipe(Effect.provide(layer)); }); @@ -570,6 +622,69 @@ describe("projects create integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("fills the real linked-project cache with the created project", () => { + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "POST" && request.url === `${API}/v1/projects`) { + return jsonResponse(request, 201, CREATED); + } + if (request.method === "GET" && request.url === `${API}/v1/projects/${CREATED.ref}`) { + return jsonResponse(request, 200, { + ...CREATED, + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, + }); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); + const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out: mockOutput({ format: "text" }), + api, + cliSettings, + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }), + Layer.succeed(ExperimentalFlag, false), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* projectsCreate({ + ...BASE_FLAGS, + name: Option.some("alpha"), + orgId: Option.some("acme"), + dbPassword: Option.some("s3cret-pass"), + region: Option.some("us-east-1"), + }); + + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `POST ${API}/v1/projects`, + `GET ${API}/v1/projects/${CREATED.ref}`, + ]); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: CREATED.ref, + }); + assertPermissionDrift({ + command: "projects create", + activeFlags: { "org-id": true, "db-password": true, region: true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + it.effect("flushes telemetry but skips cache when creation fails", () => { const { layer, telemetry, cache } = setup({ network: "fail" }); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/projects/delete/delete.command.ts b/apps/cli/src/commands/projects/delete/delete.command.ts index 0159ce28b2..814d401b5b 100644 --- a/apps/cli/src/commands/projects/delete/delete.command.ts +++ b/apps/cli/src/commands/projects/delete/delete.command.ts @@ -1,6 +1,9 @@ import { Layer } from "effect"; import { Argument, Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; @@ -24,6 +27,22 @@ export const projectsDeleteCommand = Command.make("delete", config).pipe( description: "Delete a project by ref", }, ]), + withPermissions( + compose( + { + operations: [ + { + operationId: "v1-list-all-projects", + kind: "required", + context: + "no ref argument (or an empty one) and an interactive terminal (stdin TTY and text output); SUPABASE_PROJECT_ID and supabase/.temp/project-ref are ignored", + }, + { operationId: "v1-delete-a-project", kind: "required" }, + ], + }, + linkedProjectCache, + ), + ), Command.withHandler((flags) => projectsDelete(flags).pipe( withCommandTelemetry({ flags, safeFlags: [] }), diff --git a/apps/cli/src/commands/projects/delete/delete.integration.test.ts b/apps/cli/src/commands/projects/delete/delete.integration.test.ts index 2734e7eeb0..33de70bfc7 100644 --- a/apps/cli/src/commands/projects/delete/delete.integration.test.ts +++ b/apps/cli/src/commands/projects/delete/delete.integration.test.ts @@ -8,6 +8,7 @@ import { type HttpMethod, VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, @@ -15,10 +16,13 @@ import { useTempWorkdir, withConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { YesFlag } from "../../../command-internal/global-flags.ts"; import { projectsDelete } from "./delete.handler.ts"; const OTHER_REF = "qrstuvwxyzabcdefghij"; +const API = "https://api.supabase.com"; const DELETED = { id: 1, ref: VALID_REF, name: "alpha" }; @@ -151,8 +155,65 @@ describe("projects delete integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("deletes the project chosen in the picker, then looks it up for the real cache", () => { + const out = mockOutput({ + format: "text", + promptSelectResponses: [VALID_REF], + promptConfirmResponses: [true], + }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "GET" && request.url === `${API}/v1/projects`) { + return jsonResponse(request, 200, [SAMPLE_PROJECT]); + } + if (request.method === "GET" && request.url === `${API}/v1/projects/${VALID_REF}`) { + return jsonResponse(request, 404, { message: "not found" }); + } + if (request.method === "DELETE" && request.url === `${API}/v1/projects/${VALID_REF}`) { + return jsonResponse(request, 200, DELETED); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + stdin: mockStdin(true), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }), + Layer.succeed(YesFlag, false), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* projectsDelete({ ref: Option.none() }); + + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${API}/v1/projects`, + `DELETE ${API}/v1/projects/${VALID_REF}`, + `GET ${API}/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain("Deleted project: alpha"); + expect(yield* fs.exists(cachePath)).toBe(false); + assertPermissionDrift({ command: "projects delete", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + it.effect("fails when no ref is given on a non-TTY", () => { - const { layer, cache } = setup({ stdinIsTty: false }); + const { layer, cache, api } = setup({ stdinIsTty: false }); return Effect.gen(function* () { const exit = yield* Effect.exit(projectsDelete({ ref: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); @@ -160,6 +221,7 @@ describe("projects delete integration", () => { expect(Cause.pretty(exit.cause)).toContain("ProjectsDeleteRefRequiredError"); } expect(cache.cached).toBe(false); + expect(api.requests).toEqual([]); }).pipe(Effect.provide(layer)); }); @@ -186,6 +248,7 @@ describe("projects delete integration", () => { expect(out.stderrText).toContain("Do you want to delete project "); expect(out.stderrText).toContain("? This action is irreversible. [y/N] y\n"); expect(hasMethod(api, "DELETE")).toBe(true); + assertPermissionDrift({ command: "projects delete", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)), ); }); diff --git a/apps/cli/src/commands/projects/list/list.command.ts b/apps/cli/src/commands/projects/list/list.command.ts index c8395b618c..d9169ef0a5 100644 --- a/apps/cli/src/commands/projects/list/list.command.ts +++ b/apps/cli/src/commands/projects/list/list.command.ts @@ -1,6 +1,9 @@ import { Command } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withContext } from "../../../command-internal/command-permissions/model.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -22,6 +25,15 @@ export const projectsListCommand = Command.make("list", config).pipe( description: "Machine-readable JSON output", }, ]), + withPermissions( + compose( + { operations: [{ operationId: "v1-list-all-projects", kind: "required" }] }, + withContext( + linkedProjectCache, + "a linked ref from SUPABASE_PROJECT_ID or supabase/.temp/project-ref", + ), + ), + ), Command.withHandler((flags) => projectsList(flags).pipe(withCommandTelemetry({ flags, safeFlags: [] }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/projects/list/list.handler.ts b/apps/cli/src/commands/projects/list/list.handler.ts index 67564daf84..244cfa94c4 100644 --- a/apps/cli/src/commands/projects/list/list.handler.ts +++ b/apps/cli/src/commands/projects/list/list.handler.ts @@ -77,9 +77,8 @@ export const projectsList = Effect.fn("projects.list")(function* (_flags: Projec const fetching = output.format === "text" ? yield* output.task("Fetching projects...") : undefined; - // `executeRaw` skips response decoding: the generated `ref` schema requires 20+ lowercase - // letters, which placeholder refs in test fixtures don't satisfy. Auth, URL, and headers - // still go through the API client. + // `executeRaw` skips response decoding so projects are emitted as the API returned them, + // including fields the generated schema does not declare. const response = yield* api.executeRaw(operationDefinitions.v1ListAllProjects, {}).pipe( Effect.tapError(() => fetching?.fail() ?? Effect.void), Effect.mapError( diff --git a/apps/cli/src/commands/projects/list/list.integration.test.ts b/apps/cli/src/commands/projects/list/list.integration.test.ts index 2837af106b..a9404f2684 100644 --- a/apps/cli/src/commands/projects/list/list.integration.test.ts +++ b/apps/cli/src/commands/projects/list/list.integration.test.ts @@ -6,12 +6,15 @@ import { mockOutput } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; import { projectsList } from "./list.handler.ts"; type Projects = typeof V1ListAllProjectsOutput.Type; @@ -51,6 +54,9 @@ const PARENT_PROJECT: Projects[number] = { }; const tempRoot = useTempWorkdir("supabase-projects-list-int-"); +const API = "https://api.supabase.com"; + +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); const LinkedProjectsPayload = Schema.Struct({ projects: Schema.Array(Schema.Struct({ id: Schema.String, linked: Schema.Boolean })), @@ -140,9 +146,10 @@ function setupTracked(opts: SetupOpts = {}) { describe("projects list integration", () => { it.effect("renders a Glamour table with all six columns in text mode", () => { - const { layer, out } = setup({ response: [SAMPLE_PROJECT, OTHER_PROJECT] }); + const { layer, out, api } = setup({ response: [SAMPLE_PROJECT, OTHER_PROJECT] }); return Effect.gen(function* () { yield* projectsList({}); + assertPermissionDrift({ command: "projects list", requests: api.requests, exact: true }); expect(out.stdoutText).toContain("LINKED"); expect(out.stdoutText).toContain("ORG ID"); expect(out.stdoutText).toContain("REFERENCE ID"); @@ -358,14 +365,14 @@ describe("projects list integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("tolerates placeholder/short refs in the response (lenient parse)", () => { - // The typed client rejects refs under 20 chars; the raw-HTTP path renders them verbatim - // so placeholder fixtures still work. - const placeholder = { ...SAMPLE_PROJECT, id: "__PROJECT_REF__", ref: "__PROJECT_REF__" }; - const { layer, out } = setup({ response: [placeholder] }); + it.effect("passes through project fields the schema does not declare", () => { + const { layer, out } = setup({ + outputFlag: "json", + response: [{ ...SAMPLE_PROJECT, undeclared_field: "kept" }], + }); return Effect.gen(function* () { yield* projectsList({}); - expect(out.stdoutText).toContain("__PROJECT_REF__"); + expect(out.stdoutText).toContain('"undeclared_field": "kept"'); }).pipe(Effect.provide(layer)); }); @@ -386,4 +393,48 @@ describe("projects list integration", () => { expect(cache.cached).toBe(false); }).pipe(Effect.provide(layer)); }); + + it.effect("fills the real linked-project cache for a ref saved in supabase/.temp", () => { + const out = mockOutput({ format: "text" }); + const api = mockCommandPlatformApi({ + handler: (request) => + Effect.sync(() => { + if (request.method === "GET" && request.url === `${API}/v1/projects`) { + return jsonResponse(request, 200, [SAMPLE_PROJECT]); + } + if (request.method === "GET" && request.url === `${API}/v1/projects/${VALID_REF}`) { + return jsonResponse(request, 200, SAMPLE_PROJECT); + } + return jsonResponse(request, 404, { message: "unexpected request" }); + }), + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* writeProjectRefFile(tempRoot.current, VALID_REF); + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + + yield* projectsList({}); + + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${API}/v1/projects`, + `GET ${API}/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain("●"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "projects list", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/cli/src/commands/projects/projects.format.ts b/apps/cli/src/commands/projects/projects.format.ts index 3dc996cd3b..e5347f9a5d 100644 --- a/apps/cli/src/commands/projects/projects.format.ts +++ b/apps/cli/src/commands/projects/projects.format.ts @@ -8,9 +8,8 @@ import { formatTimestamp } from "../../command-internal/timestamp.format.ts"; type ApiKey = typeof ApiKeyResponse_Output.Type; /** - * Lenient project record. `projects list`/`create` parse the `/v1/projects` response via the raw - * HTTP client because the typed schema's `ref` pattern (20+ lowercase letters) rejects placeholder - * refs in test fixtures, so projects flow through as plain JSON objects. + * Lenient project record. `projects list`/`create` read the `/v1/projects` response without + * schema decoding, so projects flow through as plain JSON objects exactly as the API returned them. */ export type LinkedProject = Readonly> & { readonly linked: boolean }; diff --git a/apps/cli/src/commands/pull/pull.command.ts b/apps/cli/src/commands/pull/pull.command.ts index 04d7703b5f..da6719d029 100644 --- a/apps/cli/src/commands/pull/pull.command.ts +++ b/apps/cli/src/commands/pull/pull.command.ts @@ -2,7 +2,17 @@ import { Option } from "effect"; import type * as CliCommand from "effect/cli/Command"; import { Command, Flag } from "effect/cli"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withCondition } from "../../command-internal/command-permissions/model.ts"; +import { configPullRun } from "../../command-internal/config-pull-run.ts"; +import { + dbConfigLinked, + dbConfigPoolerFallbackLinked, +} from "../../command-internal/db-config.layer.ts"; +import { configTarget } from "../../command-internal/project-target.ts"; import { PROJECT_REF_PATTERN } from "../../config/project-ref.service.ts"; +import { functionsDownload } from "../../shared/functions/download.ts"; +import { linkedProjectCache } from "../../telemetry/linked-project-cache.layer.ts"; import { withCliConfigFlags } from "../../config/cli-config-flags.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { GLOBAL_OUTPUT_FORMATS } from "../../command-internal/global-flags.ts"; @@ -43,6 +53,10 @@ const config = { ), } as const; +// `--dry-run` returns after the target and config reads, so the database and function steps run +// only without it. +const stepsRun = { flag: "dry-run", is: false } as const; + export type PullFlags = CliCommand.Command.Config.Infer; // Exported so integration tests can drive the exact wiring `Command.withHandler` @@ -84,6 +98,28 @@ export const pullCommand = Command.make("pull", config).pipe( description: "Preview what would be pulled without writing anything", }, ]), + withPermissions( + compose( + configTarget, + configPullRun, + linkedProjectCache, + { + operations: [ + { + operationId: "v1-get-pooler-config", + kind: "required", + when: [stepsRun], + context: + "the direct database host is unreachable (IPv4-only network) and there is no usable saved supabase/.temp/pooler-url; the database steps get the resolved ref, so this needs no --project-ref", + }, + ], + noApiEffectFlags: ["remote-label", "force", "with-migration-history"], + }, + withCondition(dbConfigLinked, stepsRun), + withCondition(dbConfigPoolerFallbackLinked, stepsRun), + withCondition(functionsDownload, stepsRun), + ), + ), Command.withHandler(pullHandler), Command.provide(pullRuntimeLayer), Command.provide(cliConfigValuesLayer), diff --git a/apps/cli/src/commands/pull/pull.integration.test.ts b/apps/cli/src/commands/pull/pull.integration.test.ts index 6d59f7a813..e315155e08 100644 --- a/apps/cli/src/commands/pull/pull.integration.test.ts +++ b/apps/cli/src/commands/pull/pull.integration.test.ts @@ -17,8 +17,33 @@ import { import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { TestClock } from "effect/testing"; +import { vi } from "vitest"; + +const fixtureNet = vi.hoisted(() => ({ directHostReachable: false })); + +vi.mock("node:net", (importOriginal) => + Promise.all([ + import("../../../tests/helpers/refuse-invalid-db-hosts.ts"), + importOriginal(), + ]).then(([helper, net]) => helper.refuseInvalidDbHosts(net, fixtureNet)), +); + +const reachableDirectHost = withReachableDirectHost(fixtureNet); import { v2ProjectConfigResponse } from "../../../tests/helpers/config-fixtures.ts"; +import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; +import { + asRecordedRequests, + projectResponse, + provideEnv, + realDbConfigResolver, + realLinkedProjectCache, + recordingProjectHttpClient, + serveLinkedDatabase, + stubManagementApi, + type StubbedRequest, +} from "../../../tests/helpers/permission-real-layers.ts"; +import { withReachableDirectHost } from "../../../tests/helpers/refuse-invalid-db-hosts.ts"; import { mockContextualAnalytics, mockOutput, @@ -31,6 +56,7 @@ import { configValuesLayer } from "../../../tests/helpers/config-values-layer.ts import { buildTestRuntime, VALID_REF, + VALID_TOKEN, jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, @@ -78,6 +104,9 @@ import { pullHandler } from "./pull.command.ts"; const tempRoot = useTempWorkdir("supabase-pull-int-"); +/** The effective values of `pull`'s three switches when none is named. */ +const STEP_SWITCHES = { "dry-run": false, force: false, "with-migration-history": false }; + const BRANCH_REF = "cccccccccccccccccccc"; const BRANCH_BY_NAME = { @@ -362,6 +391,7 @@ function makeDbConfigLayers( historyUpdateFails = false, ): { readonly layer: Layer.Layer; + readonly dbConnection: Layer.Layer; readonly historyUpserts: ReadonlyArray>; readonly connectedPorts: ReadonlyArray; } { @@ -397,6 +427,7 @@ function makeDbConfigLayers( return { layer: Layer.mergeAll(dbConnection, resolver), + dbConnection, historyUpserts: target.historyUpserts, connectedPorts, }; @@ -481,12 +512,17 @@ interface ApiOpts { /** Fails only this slug's `/body` download with a 500; every other slug still downloads * normally, proving a partial functions-step download reports earlier slugs as written. */ readonly functionBodyFailsForSlug?: string; + /** Serves `GET /v1/projects`, which the project picker reads. */ + readonly projectList?: boolean; } function makeApiMock(opts: ApiOpts) { return mockCommandPlatformApi({ handler: (request) => { const url = request.url; + if (opts.projectList === true && request.method === "GET" && url.endsWith("/v1/projects")) { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } if (url.includes("/v2/projects/") && url.endsWith("/config")) { return Effect.succeed( jsonResponse(request, 200, opts.configResponse ?? v2ProjectConfigResponse()), @@ -607,6 +643,13 @@ interface SetupOpts { readonly workdir?: string; /** Overrides the global `--experimental` flag — defaults to `false`. */ readonly experimental?: boolean; + /** Runs the real database config resolver and linked-project cache, so their Management API + * calls are recorded. The direct database host is unreachable unless a test opts in. */ + readonly realLayers?: boolean; + /** Overrides the linked project ref in settings — defaults to `Some(VALID_REF)`. */ + readonly projectId?: Option.Option; + /** Serves `GET /v1/projects` and answers the project picker with `VALID_REF`. */ + readonly picksProject?: boolean; } interface MachineSetupOpts extends SetupOpts { @@ -614,7 +657,11 @@ interface MachineSetupOpts extends SetupOpts { } function setup(opts: SetupOpts = {}) { - const out = mockOutput({ format: "text", promptConfirmResponses: opts.confirm }); + const out = mockOutput({ + format: "text", + promptConfirmResponses: opts.confirm, + ...(opts.picksProject === true ? { promptSelectResponses: [VALID_REF] } : {}), + }); const outputLayer = opts.confirmSideEffect === undefined ? out.layer @@ -643,7 +690,11 @@ function setupRuntime( const processControl = mockProcessControl(); const analytics = mockContextualAnalytics(); - const api = makeApiMock(opts.api ?? {}); + const api = makeApiMock({ + ...opts.api, + ...(opts.picksProject === true ? { projectList: true } : {}), + }); + const projectHttp = recordingProjectHttpClient(VALID_REF); const spawner = composeSpawner({ gitDirty: opts.gitDirty, gitDirtyMigrations: opts.gitDirtyMigrations, @@ -658,9 +709,11 @@ function setupRuntime( ); const pgDelta = makePgDeltaEngine(opts.diffOutcome ?? (() => ({ changes: false }))); + const workdir = opts.workdir ?? tempRoot.current; const cliSettings = mockCommandSettings({ - workdir: opts.workdir ?? tempRoot.current, - projectId: Option.some(VALID_REF), + workdir, + projectId: opts.projectId ?? Option.some(VALID_REF), + ...(opts.realLayers === true ? { projectHost: "invalid" } : {}), }); const layer = Layer.mergeAll( @@ -673,7 +726,13 @@ function setupRuntime( stdin: mockStdin(opts.stdinIsTty ?? false), runtimeInfo: mockRuntimeInfo({ cwd: tempRoot.current }), telemetry: telemetry.layer, - linkedProjectCache: linkedProjectCache.layer, + linkedProjectCache: + opts.realLayers === true + ? realLinkedProjectCache({ + httpClient: projectHttp.layer, + cliSettings: mockCommandSettings({ workdir }), + }) + : linkedProjectCache.layer, processControl: { layer: processControl.layer }, analytics: { layer: analytics.layer }, outputFlag: opts.outputFlag ?? Option.none(), @@ -681,7 +740,16 @@ function setupRuntime( machineErrorContextLayer, commandRuntimeLayer(["pull"]).pipe(Layer.provide(BunServices.layer)), stdioLayer, - dbConfig.layer, + opts.realLayers === true + ? Layer.mergeAll( + dbConfig.dbConnection, + realDbConfigResolver({ + workdir, + dbConnection: dbConfig.dbConnection, + env: { SUPABASE_ACCESS_TOKEN: VALID_TOKEN }, + }), + ) + : dbConfig.layer, pgDelta.layer, Layer.succeed(EdgeRuntimeScript, { run: () => Effect.die("migra edge runtime unused — every db step forces pg-delta"), @@ -725,6 +793,7 @@ function setupRuntime( dbConfig, pgDelta, callOrder, + projectHttp, }; } @@ -845,6 +914,11 @@ describe("pull integration", () => { const configLookups = api.requests.filter((request) => request.url.endsWith("/config")); expect(configLookups).toHaveLength(1); expect(out.promptSelectCalls).toHaveLength(0); + assertPermissionDrift({ + command: "pull", + activeFlags: { ...STEP_SWITCHES, "project-ref": "staging" }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }, ); @@ -928,6 +1002,11 @@ describe("pull integration", () => { expect(yield* readConfigFile).toBe(before); expect(yield* projectFileExists("supabase", "migrations")).toBe(false); expect(api.requests.some((request) => request.method !== "GET")).toBe(false); + assertPermissionDrift({ + command: "pull", + activeFlags: { ...STEP_SWITCHES, "dry-run": true }, + requests: api.requests, + }); expect(stepLine(out.stdoutText, "config")).toContain("planned"); expect(stepLine(out.stdoutText, "migration_history")).toContain("planned"); @@ -2106,3 +2185,226 @@ describe("pull stream-json output", () => { }, ); }); + +describe("pull permissions through the real layers", () => { + const asLines = (requests: ReadonlyArray) => + requests.map(({ method, path }) => `${method} ${path}`); + const poolerRequest = `GET /v1/projects/${VALID_REF}/config/database/pooler`; + const loginRoleRequest = `POST /v1/projects/${VALID_REF}/cli/login-role`; + const cacheRequest = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; + const configRequest = `GET https://api.supabase.com/v2/projects/${VALID_REF}/config`; + const asApiLines = (requests: ReadonlyArray<{ readonly method: string; readonly url: string }>) => + requests.map(({ method, url }) => `${method} ${url}`); + + const bootstrapSetup = () => + setup({ + yes: true, + realLayers: true, + api: { functionSlugs: ["hello"] }, + remoteMigrations: [ + { version: "20260101000000", name: "init", statements: ["create table foo ();"] }, + ], + diffOutcome: () => ({ + changes: true, + files: [{ name: "pull", sql: "alter table foo add column bar text;" }], + }), + }); + + // No `.temp/pooler-url` is saved between steps, so each database step resolves it again. + const FULL_RUN_SEQUENCE: ReadonlyArray = [ + poolerRequest, + loginRoleRequest, + poolerRequest, + loginRoleRequest, + ]; + + const REF_SCENARIOS: ReadonlyArray<{ + readonly name: string; + readonly projectRef: Option.Option; + readonly flag: Readonly>; + }> = [ + { name: "no --project-ref", projectRef: Option.none(), flag: {} }, + { + name: "--project-ref with the ref", + projectRef: Option.some(VALID_REF), + flag: { "project-ref": VALID_REF }, + }, + { + name: "an empty --project-ref, which resolves from settings", + projectRef: Option.some(""), + flag: { "project-ref": "" }, + }, + ]; + + for (const scenario of REF_SCENARIOS) { + it.effect( + `reads the config, fetches the history, pulls the schema and downloads functions for ${scenario.name}`, + () => { + const { layer, api, projectHttp } = bootstrapSetup(); + const stubbed: Array = []; + return Effect.gen(function* () { + yield* TestClock.setTime(AFTER_SEEDED_MIGRATIONS); + yield* stubManagementApi(stubbed, serveLinkedDatabase(VALID_REF)); + yield* writeConfig("[api]\nmax_rows = 500\n"); + yield* runPull(pullFlags({ projectRef: scenario.projectRef })); + + expect(asLines(stubbed)).toEqual(FULL_RUN_SEQUENCE); + expect(asApiLines(api.requests)).toEqual([ + configRequest, + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}/functions/hello/body`, + ]); + expect(asApiLines(projectHttp.calls)).toEqual([cacheRequest]); + expect(yield* projectFileExists("supabase", ".temp", "linked-project.json")).toBe(true); + assertPermissionDrift({ + command: "pull", + activeFlags: { ...STEP_SWITCHES, ...scenario.flag }, + requests: [...api.requests, ...asRecordedRequests(stubbed), ...projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }, + ); + } + + it.effect("--dry-run reads the config and fills the cache, and makes no other request", () => { + const { layer, api, projectHttp } = setup({ + realLayers: true, + api: { functionSlugs: ["hello"] }, + }); + const stubbed: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(stubbed, serveLinkedDatabase(VALID_REF)); + yield* writeConfig(); + yield* runPull(pullFlags({ dryRun: true })); + + expect(asLines(stubbed)).toEqual([]); + expect(asApiLines(api.requests)).toEqual([configRequest]); + expect(asApiLines(projectHttp.calls)).toEqual([cacheRequest]); + const flags = { ...STEP_SWITCHES, "dry-run": true }; + assertPermissionDrift({ + command: "pull", + activeFlags: flags, + requests: [...api.requests, ...asRecordedRequests(stubbed), ...projectHttp.calls], + exact: true, + }); + expect(() => + assertPermissionDrift({ + command: "pull", + activeFlags: flags, + requests: [ + { + method: "POST", + url: `https://api.supabase.com/v1/projects/${VALID_REF}/cli/login-role`, + }, + ], + }), + ).toThrow(/condition doesn't hold/); + }).pipe( + Effect.scoped, + Effect.provide(layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("the project picker lists projects and the run still fills the real cache", () => { + const { layer, api, projectHttp, out } = setup({ + realLayers: true, + picksProject: true, + projectId: Option.none(), + stdinIsTty: true, + }); + const stubbed: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(stubbed, serveLinkedDatabase(VALID_REF)); + yield* writeConfig(); + yield* runPull(pullFlags({ dryRun: true })); + + expect(out.promptSelectCalls).toHaveLength(1); + expect(asApiLines(api.requests)).toEqual([ + "GET https://api.supabase.com/v1/projects", + configRequest, + ]); + expect(asApiLines(projectHttp.calls)).toEqual([cacheRequest]); + assertPermissionDrift({ + command: "pull", + activeFlags: { ...STEP_SWITCHES, "dry-run": true }, + requests: [...api.requests, ...asRecordedRequests(stubbed), ...projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("--with-migration-history on a populated directory resolves the database again", () => { + const { layer, api, projectHttp } = setup({ + yes: true, + realLayers: true, + api: { functionSlugs: ["hello"] }, + remoteMigrations: [{ version: "20260101000000", name: "init", statements: ["select 1;"] }], + }); + const stubbed: Array = []; + return Effect.gen(function* () { + yield* stubManagementApi(stubbed, serveLinkedDatabase(VALID_REF)); + yield* writeConfig(); + yield* seedLocalMigration("20260101000000", "init"); + yield* runPull(pullFlags({ withMigrationHistory: true })); + + expect(asLines(stubbed)).toEqual(FULL_RUN_SEQUENCE); + assertPermissionDrift({ + command: "pull", + activeFlags: { ...STEP_SWITCHES, "with-migration-history": true }, + requests: [...api.requests, ...asRecordedRequests(stubbed), ...projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); + + it.effect("retries the schema diff through the pooler after an IPv6 failure", () => { + let diffs = 0; + const { layer, api, projectHttp } = setup({ + yes: true, + realLayers: true, + api: { functionSlugs: ["hello"] }, + remoteMigrations: [{ version: "20260101000000", name: "init", statements: ["select 1;"] }], + diffOutcome: () => { + diffs += 1; + return diffs === 1 + ? { changes: false, fail: "could not translate host name: network is unreachable" } + : { changes: false }; + }, + }); + const stubbed: Array = []; + return Effect.gen(function* () { + yield* reachableDirectHost; + yield* stubManagementApi(stubbed, serveLinkedDatabase(VALID_REF)); + yield* writeConfig(); + yield* seedLocalMigration("20260101000000"); + yield* runPull(pullFlags()); + + expect(diffs).toBe(2); + expect(asLines(stubbed)).toEqual([loginRoleRequest, poolerRequest, loginRoleRequest]); + assertPermissionDrift({ + command: "pull", + activeFlags: STEP_SWITCHES, + requests: [...api.requests, ...asRecordedRequests(stubbed), ...projectHttp.calls], + exact: true, + }); + }).pipe( + Effect.scoped, + Effect.provide(layer), + provideEnv({ SUPABASE_ACCESS_TOKEN: VALID_TOKEN }), + ); + }); +}); diff --git a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md index a7a5de3082..392d514bd4 100644 --- a/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/set/SIDE_EFFECTS.md @@ -9,23 +9,25 @@ | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | | `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/linked-project.json` | JSON | existence check before the cache fill (in `Effect.ensuring` after the project ref resolves) | | `/supabase/config.json` or `config.toml` | JSON / TOML | always (for `[edge_runtime.secrets]`, `config.json` preferred) — via the `CliConfigValues` resolved config, with a matching `[remotes.*]` block applied; a parse failure is debug-logged and tolerated, a removed key is fatal. | | `/.env*`, `/supabase/.env*` | dotenv | always — project env files selected by `SUPABASE_ENV` (default `development`), context for `env(VAR)` interpolation in `[edge_runtime.secrets]` values; a variable the shell sets is never taken from a file | | `` (absolute or CWD-relative) | dotenv | when `--env-file` flag is provided | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ------------------------------------------------------------ | -| `~/.supabase//linked-project.json` | JSON | always (in `Effect.ensuring`) after `--project-ref` resolves | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the project ref resolves — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------- | ------------ | -------------------------------------- | ------------------------ | -| `POST` | `/v1/projects/{ref}/secrets` | Bearer token | `[{name: string, value: string}, ...]` | none (201 expected) | -| `GET` | `/v1/projects` | Bearer token | none | TTY-prompt fallback only | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------- | ------------ | -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `POST` | `/v1/projects/{ref}/secrets` | Bearer token | `[{name: string, value: string}, ...]` | none (201 expected) | +| `GET` | `/v1/projects` | Bearer token | none | project picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | ## Environment Variables @@ -81,6 +83,7 @@ One `result` NDJSON event on success containing `{project_ref, count}`. - Source order for merging entries: `[edge_runtime.secrets]` from `config.toml` (only resolved entries — see below) → `--env-file` (overrides config) → CLI args (overrides env-file). - `SUPABASE_`-prefixed entries are skipped post-merge with a stderr warning. +- Secrets are POSTed in batches of 100 (the API's per-request cap); every batch is validated before the first POST. When no secrets remain after filtering, the command fails with `SecretsNoArgumentsError` and makes no POST (the cache fill still runs). - `[edge_runtime.secrets]` from config.toml is read through the `CliConfigValues` resolved config (shell env > project `.env*` > config for `env(VAR)` references) + `resolveCliSubtree`. Resolved secret values arrive wrapped in `Redacted`; unresolved `env(VAR)` literals (env var unset) stay as plain strings and are filtered out at the handler (secrets whose value never resolved past the literal `env(VAR)` form are dropped). - A removed config key or a non-empty `SUPABASE_EXPERIMENTAL_ORIOLEDB_VERSION` **does** abort the command (exit 1, before any request) instead of being tolerated: silently dropping the config's `[edge_runtime.secrets]` would send a partial secret set. - A malformed `config.toml` does **not** abort the command — the error is logged to the debug logger and the command proceeds. `--env-file` and positional `NAME=VALUE` secrets always still apply. What happens to config-declared secrets depends on the failure class: a raw TOML/JSON syntax error drops everything (no `EdgeRuntime.Secrets`), but a schema-type error on an _unrelated_ field (e.g. `analytics.port` being a string) still leaves a valid `[edge_runtime.secrets]` section usable — the handler recovers it by re-decoding just that subtree. Pass `--debug` to see the logged parse error. diff --git a/apps/cli/src/commands/secrets/set/set.command.ts b/apps/cli/src/commands/secrets/set/set.command.ts index fca3bd1391..da14bb2d6b 100644 --- a/apps/cli/src/commands/secrets/set/set.command.ts +++ b/apps/cli/src/commands/secrets/set/set.command.ts @@ -1,8 +1,12 @@ import type * as CliCommand from "effect/cli/Command"; import { Argument, Command, Flag } from "effect/cli"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -38,6 +42,16 @@ export const secretsSetCommand = Command.make("set", config).pipe( description: "Set secrets from a .env file", }, ]), + withPermissions( + compose( + { + operations: [{ operationId: "v1-bulk-create-secrets", kind: "required" }], + noApiEffectFlags: ["env-file"], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => secretsSet(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/secrets/set/set.integration.test.ts b/apps/cli/src/commands/secrets/set/set.integration.test.ts index 54ceedd8c8..8e4314acb3 100644 --- a/apps/cli/src/commands/secrets/set/set.integration.test.ts +++ b/apps/cli/src/commands/secrets/set/set.integration.test.ts @@ -13,14 +13,25 @@ import { } from "effect"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; -import { mockOutput, mockRuntimeInfo, processEnvLayer } from "../../../../tests/helpers/mocks.ts"; +import { + mockOutput, + mockRuntimeInfo, + mockTty, + processEnvLayer, +} from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockCommandPlatformApi, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { DebugLogger } from "../../../shared/output/debug-logger.service.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; import { secretsSet } from "./set.handler.ts"; @@ -117,6 +128,8 @@ const writeConfig = (content: string) => writeSupabaseFile("config.toml", conten const writeSupabaseDotEnv = (content: string) => writeSupabaseFile(".env", content); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const decodePostBody = Schema.decodeUnknownEffect( Schema.Array(Schema.Struct({ name: Schema.String, value: Schema.String })), { onExcessProperty: "error" }, @@ -134,6 +147,7 @@ describe("secrets set integration", () => { expect(api.requests).toHaveLength(1); expect(api.requests[0]?.body).toEqual([{ name: "FOO", value: "bar" }]); expect(out.stdoutText).toBe("Finished supabase secrets set.\n"); + assertPermissionDrift({ command: "secrets set", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -173,6 +187,7 @@ describe("secrets set integration", () => { expect(names.has(`KEY${i}`)).toBe(true); } expect(out.stdoutText).toContain("Finished supabase secrets set."); + assertPermissionDrift({ command: "secrets set", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -191,6 +206,61 @@ describe("secrets set integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("lists projects for the picker and fills the real linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.method === "POST") return Effect.succeed(jsonResponse(request, 201, null)); + return Effect.succeed( + request.url === "https://api.supabase.com/v1/projects" + ? jsonResponse(request, 200, [projectResponse(VALID_REF)]) + : jsonResponse(request, 200, projectResponse(VALID_REF)), + ); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }), + mockRuntimeInfo({ cwd: tempRoot.current }), + processEnvLayer({}), + cliConfigValuesTestLayer, + mockDebugLoggerTracked().layer, + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* secretsSet({ + projectRef: Option.none(), + envFile: Option.none(), + secrets: ["FOO=bar"], + }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `POST https://api.supabase.com/v1/projects/${VALID_REF}/secrets`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(api.requests[1]?.body).toEqual([{ name: "FOO", value: "bar" }]); + expect(out.stdoutText).toBe("Finished supabase secrets set.\n"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "secrets set", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + it.effect( "rejects the whole upload when a later batch has an invalid entry (no partial update)", () => { @@ -231,6 +301,12 @@ describe("secrets set integration", () => { secrets: [], }); expect(api.requests[0]?.body).toEqual([{ name: "FROM_FILE", value: "fromvalue" }]); + assertPermissionDrift({ + command: "secrets set", + activeFlags: { "env-file": true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md index 6c8bcbbf59..35bc58a43d 100644 --- a/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/secrets/unset/SIDE_EFFECTS.md @@ -2,29 +2,30 @@ ## Files Read -| Path | Format | When | -| ----------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | -| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | -| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | -| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | -| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | -| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | +| `/proc/sys/kernel/osrelease` (Linux) | plain text | once on layer init — disables keyring on WSL (`WSL` / `Microsoft` substring match) | +| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/linked-project.json` | JSON | existence check before the cache fill (in `Effect.ensuring` after the project ref resolves) | ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | -------------------------------------------------------------------------------------------------- | -| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the ref resolves, on success and failure, only when the file is missing | -| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the project ref resolves — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | +| `~/.supabase/telemetry.json` | JSON | always (in `Effect.ensuring`) at end of command | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ---------------------------- | ------------ | --------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/secrets` | Bearer token | none | `[{name}]` — `--all` path only, filters `SUPABASE_` prefix | -| `DELETE` | `/v1/projects/{ref}/secrets` | Bearer token | `["NAME", ...]` | none (200 expected; not called with `--dry-run`) | -| `GET` | `/v1/projects` | Bearer token | none | TTY-prompt fallback only | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache fill in `Effect.ensuring`, on success and failure, only when `linked-project.json` is missing; best-effort | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ---------------------------- | ------------ | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/projects/{ref}/secrets` | Bearer token | none | `[{name}]` — `--all` path only, filters `SUPABASE_` prefix | +| `DELETE` | `/v1/projects/{ref}/secrets` | Bearer token | `["NAME", ...]` | none (200 expected; not called with `--dry-run`) | +| `GET` | `/v1/projects` | Bearer token | none | project picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | ## Environment Variables diff --git a/apps/cli/src/commands/secrets/unset/unset.command.ts b/apps/cli/src/commands/secrets/unset/unset.command.ts index 0c7bc8513a..a66a9defab 100644 --- a/apps/cli/src/commands/secrets/unset/unset.command.ts +++ b/apps/cli/src/commands/secrets/unset/unset.command.ts @@ -2,6 +2,10 @@ import type * as CliCommand from "effect/cli/Command"; import { Argument, Command, Flag } from "effect/cli"; import { Layer } from "effect"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; @@ -52,6 +56,26 @@ export const secretsUnsetCommand = Command.make("unset", config).pipe( description: "Preview which secrets --all would unset", }, ]), + withPermissions( + compose( + { + operations: [ + { + operationId: "v1-list-all-secrets", + kind: "required", + when: [{ flag: "all", is: true }], + }, + { + operationId: "v1-bulk-delete-secrets", + kind: "required", + when: [{ flag: "dry-run", is: false }], + }, + ], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => secretsUnset(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/secrets/unset/unset.integration.test.ts b/apps/cli/src/commands/secrets/unset/unset.integration.test.ts index 2aa4d56274..c4734cc373 100644 --- a/apps/cli/src/commands/secrets/unset/unset.integration.test.ts +++ b/apps/cli/src/commands/secrets/unset/unset.integration.test.ts @@ -1,6 +1,6 @@ import { type V1ListAllSecretsOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Layer, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Layer, Option, Schema } from "effect"; import { YesFlag } from "../../../command-internal/global-flags.ts"; import { ProjectRefNotLinkedError } from "../../../config/project-ref.errors.ts"; @@ -15,6 +15,11 @@ import { useTempWorkdir, withConfigEnv, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { SecretsNoArgumentsError, SecretsUnsetCancelledError, @@ -26,6 +31,8 @@ import { secretsUnset } from "./unset.handler.ts"; type SecretsList = typeof V1ListAllSecretsOutput.Type; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + interface SetupOpts { format?: "text" | "json" | "stream-json"; outputFlag?: "pretty" | "json" | "yaml" | "toml" | "env"; @@ -140,6 +147,12 @@ describe("secrets unset integration", () => { `Do you want to unset these function secrets from project ${VALID_REF}?\n • FOO\n • BAR\n\n [y/N] y\n`, ); expect(out.stdoutText).toBe("Finished supabase secrets unset.\n"); + assertPermissionDrift({ + command: "secrets unset", + activeFlags: { all: false, "dry-run": false }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -182,6 +195,70 @@ describe("secrets unset integration", () => { ); }); + it.effect("lists projects for the picker and fills the real linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const projectUrl = `https://api.supabase.com/v1/projects/${VALID_REF}`; + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.url === projectUrl) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if (request.url === `${projectUrl}/secrets` && request.method === "GET") { + return Effect.succeed( + jsonResponse(request, 200, [ + { name: "FOO", value: "d1" }, + { name: "SUPABASE_AUTH_TOKEN", value: "d2" }, + ]), + ); + } + return Effect.succeed(jsonResponse(request, 200, null)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true, stdoutIsTty: false }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }), + Layer.succeed(YesFlag, true), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* unset({ all: true }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET ${projectUrl}/secrets`, + `DELETE ${projectUrl}/secrets`, + `GET ${projectUrl}`, + ]); + expect(api.requests[2]?.body).toEqual(["FOO"]); + expect(out.stdoutText).toBe("Finished supabase secrets unset.\n"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "secrets unset", + activeFlags: { all: true, "dry-run": false }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + describe("--all", () => { it.effect("lists once, then DELETEs only the non-SUPABASE_ secrets", () => { const { layer, out, api } = setup({ yes: true, list: MIXED_SECRETS }); @@ -189,6 +266,12 @@ describe("secrets unset integration", () => { yield* unset({ all: true }); expect(methods(api)).toEqual(["GET", "DELETE"]); expect(api.requests[1]?.body).toEqual(["FOO", "BAR"]); + assertPermissionDrift({ + command: "secrets unset", + activeFlags: { all: true, "dry-run": false }, + requests: api.requests, + exact: true, + }); expect(out.stderrText).toBe( `Do you want to unset these function secrets from project ${VALID_REF}?\n • FOO\n • BAR\n\n [y/N] y\n`, ); diff --git a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts index 5bcf23923a..bcfb1b3e0c 100644 --- a/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts +++ b/apps/cli/src/commands/seed/buckets/buckets.integration.test.ts @@ -54,7 +54,10 @@ import { } from "../../../../tests/helpers/storage.ts"; import { unusedStackServices } from "../../../../tests/helpers/unused-stack.ts"; import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; -import { realLinkedProjectCache } from "../../../../tests/helpers/permission-real-layers.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { cliConfigValuesTestLayer } from "../../../../tests/helpers/config-values-layer.ts"; interface MockRoute { @@ -231,12 +234,7 @@ const setupSeedBuckets = Effect.fnUntraced(function* ( handler: (request) => Effect.succeed( request.method === "GET" && request.url.endsWith(`/v1/projects/${projectRefRef}`) - ? jsonResponse(request, 200, { - ref: projectRefRef, - name: "alpha", - organization_id: "org-123", - organization_slug: "acme", - }) + ? jsonResponse(request, 200, projectResponse(projectRefRef)) : request.method === "GET" && request.url.endsWith(`/v1/projects/${projectRefRef}/api-keys`) ? jsonResponse(request, 200, opts.apiKeys ?? defaultApiKeys) diff --git a/apps/cli/src/commands/services/services.command.ts b/apps/cli/src/commands/services/services.command.ts index a66e3bdd3e..f6438300f7 100644 --- a/apps/cli/src/commands/services/services.command.ts +++ b/apps/cli/src/commands/services/services.command.ts @@ -1,4 +1,8 @@ import { Command } from "effect/cli"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withContext } from "../../command-internal/command-permissions/model.ts"; +import { linkedServiceVersions } from "../../shared/services/services.shared.ts"; +import { linkedProjectCache } from "../../telemetry/linked-project-cache.layer.ts"; import { withCliConfigFlags } from "../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../config/cli-config-values.layer.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; @@ -13,6 +17,18 @@ export type ServicesFlags = CliCommand.Command.Config.Infer; export const servicesCommand = Command.make("services", config).pipe( Command.withDescription("Show versions of all Supabase services."), Command.withShortDescription("Show versions of all Supabase services"), + withPermissions( + compose( + withContext( + linkedServiceVersions, + "a 20-letter linked project ref (SUPABASE_PROJECT_ID, else supabase/.temp/project-ref) and an access token", + ), + withContext( + linkedProjectCache, + "a linked project ref (SUPABASE_PROJECT_ID, else supabase/.temp/project-ref; not format-checked)", + ), + ), + ), Command.withHandler((flags) => services(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/services/services.integration.test.ts b/apps/cli/src/commands/services/services.integration.test.ts index 6bd26e543c..ba6f890de0 100644 --- a/apps/cli/src/commands/services/services.integration.test.ts +++ b/apps/cli/src/commands/services/services.integration.test.ts @@ -32,6 +32,7 @@ import { processEnvLayer, } from "../../../tests/helpers/mocks.ts"; import { mockTelemetryStateTracked, useTempWorkdir } from "../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; import { dockerfileServiceImageRaw } from "../../shared/services/dockerfile-images.ts"; import { postgresImageForDbMajorVersion } from "../../shared/services/services.shared.ts"; import { textCliOutputFormatter } from "../../shared/output/text-formatter.ts"; @@ -561,6 +562,7 @@ major_version = 15 ); yield* writeTempFile(workdir, "project-ref", "abcdefghijklmnopqrst"); + const requests: Array<{ method: string; url: string }> = []; const server = yield* Effect.acquireRelease( Effect.try({ catch: (cause) => @@ -570,6 +572,7 @@ major_version = 15 hostname: "127.0.0.1", port: 0, fetch(request) { + requests.push({ method: request.method, url: request.url }); const url = new URL(request.url); if (url.pathname === "/v1/projects/abcdefghijklmnopqrst") { return Response.json({ @@ -627,6 +630,21 @@ major_version = 15 yield* services({}).pipe(Effect.provide(layer)); + const remoteRequests = requests.splice(0).map(({ method, url }) => ({ + method, + url, + path: `${new URL(url).pathname}${new URL(url).search}`, + })); + expect(remoteRequests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + "GET /v1/projects/abcdefghijklmnopqrst/api-keys?reveal=true", + "GET /v1/projects/abcdefghijklmnopqrst", + ]); + assertPermissionDrift({ + command: "services", + requests: remoteRequests, + apiUrl: server.url.origin, + exact: true, + }); expect(out.stderrText).not.toContain(INVALID_PROJECT_REF_MESSAGE); const rows = yield* decodeServiceRows(out.stdoutText); expect(rows).toContainEqual( diff --git a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md index 3ca88314b0..f75991e58b 100644 --- a/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/snippets/download/SIDE_EFFECTS.md @@ -12,19 +12,23 @@ ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------ | -| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | -| `/supabase/.temp/linked-project.json` | JSON | best-effort after `--project-ref` resolves | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | +| `/supabase/.temp/linked-project.json` | JSON | best-effort after the project ref resolves — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------ | -| `GET` | `/v1/snippets/{id}` | Bearer token | none | `{content: {sql, schema_version, favorite?}, id, name, visibility, owner, project, inserted_at, updated_at}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------- | ------------ | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/v1/snippets/{id}` | Bearer token | none | `{content: {sql, schema_version, favorite?}, id, name, visibility, owner, project, inserted_at, updated_at}` | +| `GET` | `/v1/projects` | Bearer token | none | project picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref`, stdin is a TTY and output is interactive | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | linked-project cache fill — best-effort, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored | Only `content.sql` is rendered in text mode. The full payload is exposed via `--output-format json`. +The snippet request carries no project ref and no query. The ref is resolved only so the cache fill can run, so the command still fails when no project is linked. + ## Environment Variables | Variable | Purpose | Required? | @@ -45,9 +49,9 @@ Only `content.sql` is rendered in text mode. The full payload is exposed via `-- ## Telemetry Events Fired -| Event | When | Notable properties | -| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------- | -| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` allowed verbatim) | +| Event | When | Notable properties | +| ---------------------- | ------------------------------------------ | -------------------------------------------------------------------- | +| `cli_command_executed` | post-run, success or failure (via wrapper) | `exit_code`, `duration_ms`, `flags` (`--project-ref` → ``) | ## Output diff --git a/apps/cli/src/commands/snippets/download/download.command.ts b/apps/cli/src/commands/snippets/download/download.command.ts index 16872c7f74..9bb2ae8201 100644 --- a/apps/cli/src/commands/snippets/download/download.command.ts +++ b/apps/cli/src/commands/snippets/download/download.command.ts @@ -1,6 +1,10 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -26,6 +30,13 @@ export const snippetsDownloadCommand = Command.make("download", config).pipe( description: "Download the SQL contents of the given snippet", }, ]), + withPermissions( + compose( + { operations: [{ operationId: "v1-get-a-snippet", kind: "required" }] }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => snippetsDownload(flags).pipe( // No `safeFlags` — `--project-ref` is not on the telemetry-safe list, diff --git a/apps/cli/src/commands/snippets/download/download.handler.ts b/apps/cli/src/commands/snippets/download/download.handler.ts index 950d6c3ef5..b0c255da76 100644 --- a/apps/cli/src/commands/snippets/download/download.handler.ts +++ b/apps/cli/src/commands/snippets/download/download.handler.ts @@ -1,13 +1,11 @@ -import { Effect, Option, Predicate, Result, Schema } from "effect"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import { operationDefinitions } from "@supabase/api/effect"; +import { Effect, Predicate, Result, Schema } from "effect"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import { CommandSettings } from "../../../config/command-settings.service.ts"; +import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { Output } from "../../../shared/output/output.service.ts"; -import { resolveAccessToken } from "../../../command-internal/resolve-token.ts"; -import { requestWithAuth } from "../../../command-internal/raw-http.ts"; -import { sanitizeErrorBody } from "../../../command-internal/http-errors.ts"; +import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { quoteBytes } from "../../../command-internal/byte-quote.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; @@ -106,23 +104,23 @@ export function parseSnippetUuid(input: string): Result.Result { return Result.succeed(canonicalFromHex(segments.join(""))); } -// Tolerant body parse — see `list.handler.ts` for the rationale. The real -// `/v1/snippets/{id}` payload omits `description`, which the generated schema -// requires, so routing through the typed client fails on real responses. -function asRecord(obj: unknown): Record { - return Predicate.isObjectOrArray(obj) ? { ...obj } : {}; -} +const asRecord = (value: unknown): Record => + Predicate.isObjectOrArray(value) ? { ...value } : {}; + +const decodeSnippet = Schema.decodeUnknownEffect(operationDefinitions.v1GetASnippet.outputSchema); -const decodeSnippetSql = Schema.decodeUnknownOption( - Schema.Struct({ content: Schema.Struct({ sql: Schema.String }) }), -); +const mapDownloadError = mapHttpError({ + networkError: SnippetsDownloadNetworkError, + statusError: SnippetsDownloadUnexpectedStatusError, + networkMessage: (cause) => `failed to download snippet: ${cause}`, + statusMessage: (status, body) => `unexpected download snippet status ${status}: ${body}`, +}); export const snippetsDownload = Effect.fn("snippets.download")(function* ( flags: SnippetsDownloadFlags, ) { const output = yield* Output; - const httpClient = yield* HttpClient.HttpClient; - const cliSettings = yield* CommandSettings; + const api = yield* CommandPlatformApi; const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; @@ -137,46 +135,30 @@ export const snippetsDownload = Effect.fn("snippets.download")(function* ( ), ); - const tokenOpt = yield* resolveAccessToken; - const request = requestWithAuth( - HttpClientRequest.get(`${cliSettings.apiUrl}/v1/snippets/${snippetId}`), - tokenOpt, - cliSettings.userAgent, - ); - const fetching = output.format === "text" ? yield* output.task("Downloading snippet...") : undefined; - const response = yield* httpClient.execute(request).pipe( - Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.mapError( - (cause) => - new SnippetsDownloadNetworkError({ - message: `failed to download snippet: ${cause.reason.description ?? cause.reason._tag}`, - }), - ), - ); - - if (response.status !== 200) { - yield* fetching?.fail() ?? Effect.void; - const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - const body = sanitizeErrorBody(rawBody); - return yield* new SnippetsDownloadUnexpectedStatusError({ - status: response.status, - body, - message: `unexpected download snippet status ${response.status}: ${body}`, - }); - } - + // `executeRaw` keeps the response body intact so JSON output emits it as the API returned + // it; the typed schema decodes a copy for the SQL. + const response = yield* api + .executeRaw(operationDefinitions.v1GetASnippet, { id: snippetId }) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.catch(mapDownloadError), + Effect.tapError(() => fetching?.fail() ?? Effect.void), + ); const rawBody = yield* response.json.pipe( Effect.mapError( (cause) => new SnippetsDownloadNetworkError({ message: `failed to download snippet: ${String(cause)}`, - // 200-response body decode failure — an API-response problem, not - // a transport/network failure. decode: true, }), ), + Effect.tapError(() => fetching?.fail() ?? Effect.void), + ); + const snippet = yield* decodeSnippet(rawBody).pipe( + Effect.catch(mapDownloadError), + Effect.tapError(() => fetching?.fail() ?? Effect.void), ); yield* fetching?.clear ?? Effect.void; @@ -190,10 +172,7 @@ export const snippetsDownload = Effect.fn("snippets.download")(function* ( // `-o`/`--output` is ignored entirely; this always prints the raw SQL — // no branching on `OutputFlag`. - const sql = Option.match(decodeSnippetSql(rawBody), { - onNone: () => "", - onSome: ({ content }) => content.sql, - }); + const sql = snippet.content?.sql ?? ""; yield* output.raw(sql + "\n"); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); }).pipe(Effect.ensuring(telemetryState.flush)); diff --git a/apps/cli/src/commands/snippets/download/download.integration.test.ts b/apps/cli/src/commands/snippets/download/download.integration.test.ts index 9c9ea0e27a..d9152c91f6 100644 --- a/apps/cli/src/commands/snippets/download/download.integration.test.ts +++ b/apps/cli/src/commands/snippets/download/download.integration.test.ts @@ -1,16 +1,23 @@ import { type V1GetASnippetOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { + VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { snippetsDownload } from "./download.handler.ts"; @@ -49,6 +56,9 @@ interface SetupOpts { response?: unknown; } +const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-snippets-download-int-"); function setup(opts: SetupOpts = {}) { @@ -87,16 +97,40 @@ describe("snippets download integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("prints only a newline in text mode when the snippet carries no SQL string", () => { - const { layer, out } = setup({ - response: { ...SNIPPET_RESPONSE, content: { schema_version: "1.0.0" } }, - }); + it.effect("prints only a newline in text mode when the snippet has no content", () => { + const { layer, out } = setup({ response: { ...SNIPPET_RESPONSE, content: null } }); return Effect.gen(function* () { yield* snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); expect(out.stdoutText).toBe("\n"); }).pipe(Effect.provide(layer)); }); + it.effect("emits null owner, updated_by and content as the API returned them under json", () => { + const body = { ...SNIPPET_RESPONSE, owner: null, updated_by: null, content: null }; + const { layer, out } = setup({ format: "json", response: body }); + return Effect.gen(function* () { + yield* snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(toJson(success?.data)).toBe(toJson(body)); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails with a decode network error when the body does not match the contract", () => { + const { layer } = setup({ response: { id: VALID_ID } }); + return Effect.gen(function* () { + const exit = yield* Effect.exit( + snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }), + ); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "SnippetsDownloadNetworkError", + decode: true, + }); + } + }).pipe(Effect.provide(layer)); + }); + // `--output` is ignored entirely: no read of `OutputFlag`, no branching. // Guards against a future refactor adding branch-on-outputFlag logic by // mistake — if the flag is consumed, this assertion diverges. @@ -206,6 +240,7 @@ describe("snippets download integration", () => { expect(api.requests[0]?.method).toBe("GET"); expect(api.requests[0]?.url).toContain(`/v1/snippets/${VALID_ID}`); expect(api.requests[0]?.urlParams).toBe(""); + assertPermissionDrift({ command: "snippets download", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -223,12 +258,63 @@ describe("snippets download integration", () => { it.effect("uses --project-ref flag value when resolving the linked-project cache", () => { const flagRef = "zzzzzzzzzzzzzzzzzzzz"; - const { layer, cache } = setup(); + const { layer, cache, api } = setup(); return Effect.gen(function* () { yield* snippetsDownload({ snippetId: VALID_ID, projectRef: Option.some(flagRef) }); // The download endpoint itself takes only the snippet ID, but the // resolved project ref still flows into the linked-project cache write. expect(cache.cached).toBe(true); + expect(api.requests).toHaveLength(1); + assertPermissionDrift({ + command: "snippets download", + activeFlags: { "project-ref": true }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists projects for the picker, sends no ref, and fills the real cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, 200, SNIPPET_RESPONSE)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + telemetry: mockTelemetryStateTracked().layer, + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* snippetsDownload({ snippetId: VALID_ID, projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/snippets/${VALID_ID}`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(api.requests[1]?.urlParams).toBe(""); + expect(out.stdoutText).toBe(`${SQL}\n`); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "snippets download", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/snippets/list/list.handler.ts b/apps/cli/src/commands/snippets/list/list.handler.ts index bf2b4cc9ce..7f78e2ed31 100644 --- a/apps/cli/src/commands/snippets/list/list.handler.ts +++ b/apps/cli/src/commands/snippets/list/list.handler.ts @@ -1,8 +1,8 @@ -import { Effect, Option, Predicate } from "effect"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import { operationDefinitions } from "@supabase/api/effect"; +import { Effect, Option, Predicate, Schema } from "effect"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import { CommandSettings } from "../../../config/command-settings.service.ts"; +import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; import { Output } from "../../../shared/output/output.service.ts"; @@ -18,9 +18,7 @@ import { shapeString, shapeStruct, } from "../../../command-internal/struct-output.encoders.ts"; -import { resolveAccessToken } from "../../../command-internal/resolve-token.ts"; -import { requestWithAuth } from "../../../command-internal/raw-http.ts"; -import { sanitizeErrorBody } from "../../../command-internal/http-errors.ts"; +import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; import { @@ -32,21 +30,6 @@ import { import { renderSnippetsTable, type SnippetRow } from "../snippets.format.ts"; import type { SnippetsListFlags } from "./list.command.ts"; -// Tolerant accessors for the API response body. The real `/v1/snippets` -// payload omits optional fields the generated schema declares required, so -// routing through the typed client fails with `SchemaError: Missing key …`. -function readString(obj: unknown, key: string): string { - if (Predicate.hasProperty(obj, key)) { - const value = obj[key]; - return typeof value === "string" ? value : ""; - } - return ""; -} - -function asRecord(obj: unknown): Record { - return Predicate.isObjectOrArray(obj) ? { ...obj } : {}; -} - /** * Type shape for the snippets-list response, used to drive `-o yaml|toml` key * casing. `description` (`nullable.Nullable[string]`) renders as @@ -92,32 +75,24 @@ const SNIPPET_LIST_SHAPE = shapeStruct([ ], ]); -interface SnippetsResponseBody { - readonly data: ReadonlyArray; -} +const asRecord = (value: unknown): Record => + Predicate.isObjectOrArray(value) ? { ...value } : {}; -function parseSnippetsResponse(body: unknown): SnippetsResponseBody { - const data = Predicate.hasProperty(body, "data") && Array.isArray(body.data) ? body.data : []; - return { data }; -} +const decodeSnippetsList = Schema.decodeUnknownEffect( + operationDefinitions.v1ListAllSnippets.outputSchema, +); -function toSnippetRow(raw: unknown): SnippetRow { - const owner = Predicate.hasProperty(raw, "owner") ? raw.owner : undefined; - return { - id: readString(raw, "id"), - name: readString(raw, "name"), - visibility: readString(raw, "visibility"), - owner: { username: readString(owner, "username") }, - inserted_at: readString(raw, "inserted_at"), - updated_at: readString(raw, "updated_at"), - }; -} +const mapListError = mapHttpError({ + networkError: SnippetsListNetworkError, + statusError: SnippetsListUnexpectedStatusError, + networkMessage: (cause) => `failed to list snippets: ${cause}`, + statusMessage: (status, body) => `unexpected list snippets status ${status}: ${body}`, +}); export const snippetsList = Effect.fn("snippets.list")(function* (flags: SnippetsListFlags) { const output = yield* Output; const outputFlag = yield* OutputFlag; - const httpClient = yield* HttpClient.HttpClient; - const cliSettings = yield* CommandSettings; + const api = yield* CommandPlatformApi; const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; @@ -137,52 +112,33 @@ export const snippetsList = Effect.fn("snippets.list")(function* (flags: Snippet }); } - const tokenOpt = yield* resolveAccessToken; - const request = requestWithAuth( - HttpClientRequest.get(`${cliSettings.apiUrl}/v1/snippets`).pipe( - HttpClientRequest.setUrlParams({ project_ref: ref }), - ), - tokenOpt, - cliSettings.userAgent, - ); - const fetching = output.format === "text" ? yield* output.task("Fetching snippets...") : undefined; - const response = yield* httpClient.execute(request).pipe( - Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.mapError( - (cause) => - new SnippetsListNetworkError({ - message: `failed to list snippets: ${cause.reason.description ?? cause.reason._tag}`, - }), - ), - ); - - if (response.status !== 200) { - yield* fetching?.fail() ?? Effect.void; - const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - const body = sanitizeErrorBody(rawBody); - return yield* new SnippetsListUnexpectedStatusError({ - status: response.status, - body, - message: `unexpected list snippets status ${response.status}: ${body}`, - }); - } - + // `executeRaw` keeps the response body intact so JSON output emits it as the API returned + // it; the typed schema decodes a copy for rendering. + const response = yield* api + .executeRaw(operationDefinitions.v1ListAllSnippets, { project_ref: ref }) + .pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.catch(mapListError), + Effect.tapError(() => fetching?.fail() ?? Effect.void), + ); const rawBody = yield* response.json.pipe( Effect.mapError( (cause) => new SnippetsListNetworkError({ message: `failed to list snippets: ${String(cause)}`, - // 200-response body decode failure — an API-response problem, not - // a transport/network failure. decode: true, }), ), + Effect.tapError(() => fetching?.fail() ?? Effect.void), + ); + const parsed = yield* decodeSnippetsList(rawBody).pipe( + Effect.catch(mapListError), + Effect.tapError(() => fetching?.fail() ?? Effect.void), ); yield* fetching?.clear ?? Effect.void; - const parsed = parseSnippetsResponse(rawBody); const outputFlagFormat = Option.getOrUndefined(outputFlag); if (outputFlagFormat === "json") { @@ -216,7 +172,18 @@ export const snippetsList = Effect.fn("snippets.list")(function* (flags: Snippet return; } - yield* output.raw(renderSnippetsTable(parsed.data.map(toSnippetRow))); + yield* output.raw( + renderSnippetsTable( + parsed.data.map((snippet): SnippetRow => ({ + id: snippet.id, + name: snippet.name, + visibility: snippet.visibility, + owner: { username: snippet.owner?.username ?? "" }, + inserted_at: snippet.inserted_at, + updated_at: snippet.updated_at, + })), + ), + ); }).pipe(Effect.ensuring(linkedProjectCache.cache(ref))); }).pipe(Effect.ensuring(telemetryState.flush)); }); diff --git a/apps/cli/src/commands/snippets/list/list.integration.test.ts b/apps/cli/src/commands/snippets/list/list.integration.test.ts index be4d166893..bdc87953c0 100644 --- a/apps/cli/src/commands/snippets/list/list.integration.test.ts +++ b/apps/cli/src/commands/snippets/list/list.integration.test.ts @@ -71,14 +71,13 @@ const EMPTY_RESPONSE: SnippetsResponse = { interface SetupOpts { format?: "text" | "json" | "stream-json"; outputFlag?: "env" | "pretty" | "json" | "toml" | "yaml"; - // The handler consumes the raw JSON body (schema-bypass, see the handler's - // tolerant accessors), so tests may pass shapes the generated schema would - // reject — e.g. snippets without the `description` key. - response?: SnippetsResponse | { readonly data: ReadonlyArray> }; + response?: unknown; status?: number; network?: "fail"; } +const toJson = Schema.encodeSync(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-snippets-list-int-"); function setup(opts: SetupOpts = {}) { @@ -156,6 +155,82 @@ describe("snippets list integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("emits the raw response body, keys and unknown fields intact, under json", () => { + const body = { + cursor: "next-page", + data: [ + { + favorite: true, + id: SNIPPET_ID, + project: { name: "Proj", id: 1, ref: "extra-ref" }, + name: "Create table", + type: "sql", + visibility: "user", + description: null, + inserted_at: "2023-10-13T17:48:58.491Z", + updated_at: "2023-10-13T17:48:58.491Z", + owner: null, + updated_by: null, + }, + ], + }; + const { layer, out } = setup({ format: "json", response: body }); + return Effect.gen(function* () { + yield* snippetsList({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(toJson(success?.data)).toBe(toJson(body)); + }).pipe(Effect.provide(layer)); + }); + + it.effect("keeps an empty data array empty under json", () => { + const { layer, out } = setup({ format: "json", response: EMPTY_RESPONSE }); + return Effect.gen(function* () { + yield* snippetsList({ projectRef: Option.none() }); + const success = out.messages.find((m) => m.type === "success"); + expect(toJson(success?.data)).toBe('{"data":[]}'); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "renders a snippet whose owner and updated_by are null with an empty owner cell", + () => { + const { layer, out } = setup({ + response: { data: [{ ...SNIPPET_BASE, owner: null, updated_by: null }] }, + }); + return Effect.gen(function* () { + yield* snippetsList({ projectRef: Option.none() }); + const row = out.stdoutText.split("\n").find((line) => line.includes(SNIPPET_ID)); + const cells = row?.split("|").map((cell) => cell.trim()); + expect(cells?.[1]).toBe("Create table"); + expect(cells?.[3]).toBe(""); + }).pipe(Effect.provide(layer)); + }, + ); + + it.effect("renders a snippet with a visibility the contract does not list", () => { + const { layer, out } = setup({ + response: { data: [{ ...SNIPPET_BASE, visibility: "team" }] }, + }); + return Effect.gen(function* () { + yield* snippetsList({ projectRef: Option.none() }); + expect(out.stdoutText).toContain("team"); + }).pipe(Effect.provide(layer)); + }); + + it.effect("fails with a decode network error when the body does not match the contract", () => { + const { layer } = setup({ response: { data: [{ id: SNIPPET_ID }] } }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(snippetsList({ projectRef: Option.none() })); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + expect(Option.getOrUndefined(Cause.findErrorOption(exit.cause))).toMatchObject({ + _tag: "SnippetsListNetworkError", + decode: true, + }); + } + }).pipe(Effect.provide(layer)); + }); + it.effect("emits a result event under --output-format=stream-json", () => { const { layer, out } = setup({ format: "stream-json" }); return Effect.gen(function* () { @@ -204,36 +279,11 @@ describe("snippets list integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("--output=toml emits the expected bytes when no snippet has a description", () => { - // The Management API always includes `description`, so this success - // branch is unreachable in production — kept to pin the encoder bytes - // for the shape where the key is absent. - const { description: _omitted, ...withoutDescription } = SNIPPET_BASE; - const { layer, out } = setup({ - outputFlag: "toml", - response: { data: [withoutDescription] }, - }); + it.effect("--output=toml encodes an empty snippet list", () => { + const { layer, out } = setup({ outputFlag: "toml", response: EMPTY_RESPONSE }); return Effect.gen(function* () { yield* snippetsList({ projectRef: Option.none() }); - // PascalCase field names, sub-tables after primitives, 2-space indent. - expect(out.stdoutText).toBe(`[[Data]] - Favorite = false - Id = "${SNIPPET_ID}" - InsertedAt = "2023-10-13T17:48:58.491Z" - Name = "Create table" - Type = "sql" - UpdatedAt = "2023-10-13T17:48:58.491Z" - Visibility = "user" - [Data.Owner] - Id = 7.0 - Username = "supaseed" - [Data.Project] - Id = 1.0 - Name = "Proj" - [Data.UpdatedBy] - Id = 7.0 - Username = "supaseed" -`); + expect(out.stdoutText).toBe("Data = []\n"); }).pipe(Effect.provide(layer)); }); @@ -309,6 +359,16 @@ describe("snippets list integration", () => { name: "alpha", organization_id: "org-123", organization_slug: "acme", + id: VALID_REF, + region: "us-east-1", + created_at: "2026-01-01T00:00:00Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.example.com", + version: "15", + postgres_engine: "15", + release_channel: "ga", + }, }) : jsonResponse(request, 200, SINGLE_RESPONSE), ), diff --git a/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md b/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md index f80037fc67..2d764133a2 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/ssl-enforcement/get/SIDE_EFFECTS.md @@ -2,32 +2,35 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if the gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------ | ------------ | ------------ | -------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/ssl-enforcement` | Bearer token | none | `{currentConfig: {database: boolean}, appliedSuccessfully: boolean}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------ | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/ssl-enforcement` | Bearer token | none | `{currentConfig: {database: boolean}, appliedSuccessfully: boolean}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -95,8 +98,7 @@ One `result` event: ## Notes - The `--output` flag wins over `--output-format` when both are provided. -- `linked-project.json` is written **after** the project ref is resolved, regardless of - whether the subsequent API call succeeds. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is open), even when the API call fails, but only on a cache miss with a token when `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed gate writes nothing. - `telemetry.json` is written on every invocation, including failures, but only once the `--experimental` gate is open. - `ssl-enforcement` is an experimental command: `get` requires `--experimental` diff --git a/apps/cli/src/commands/ssl-enforcement/get/get.command.ts b/apps/cli/src/commands/ssl-enforcement/get/get.command.ts index a7784e1416..7d0b2c162e 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/get.command.ts +++ b/apps/cli/src/commands/ssl-enforcement/get/get.command.ts @@ -2,6 +2,10 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/output-formats.ts"; @@ -24,6 +28,15 @@ export type SslEnforcementGetFlags = CliCommand.Command.Config.Infer Effect.gen(function* () { // An invalid `-o` value must be rejected before the missing diff --git a/apps/cli/src/commands/ssl-enforcement/get/get.integration.test.ts b/apps/cli/src/commands/ssl-enforcement/get/get.integration.test.ts index 64642a2be9..29a2c1e36f 100644 --- a/apps/cli/src/commands/ssl-enforcement/get/get.integration.test.ts +++ b/apps/cli/src/commands/ssl-enforcement/get/get.integration.test.ts @@ -1,21 +1,29 @@ import { BunServices } from "@effect/platform-bun"; import { type V1GetSslEnforcementConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Path } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Path, Schema } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { sslEnforcementGet } from "./get.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const SSL_ENFORCED: typeof V1GetSslEnforcementConfigOutput.Type = { currentConfig: { database: true }, appliedSuccessfully: true, @@ -186,6 +194,60 @@ describe("ssl-enforcement get integration", () => { yield* sslEnforcementGet({ projectRef: Option.none() }); expect(api.requests).toHaveLength(1); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/ssl-enforcement`); + assertPermissionDrift({ + command: "ssl-enforcement get", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists projects for the picker and fills the real linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + const path = new URL(request.url).pathname; + if (path === "/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (path === `/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if (path === `/v1/projects/${VALID_REF}/ssl-enforcement`) { + return Effect.succeed(jsonResponse(request, 200, SSL_ENFORCED)); + } + return Effect.succeed(jsonResponse(request, 404, { message: "unexpected request" })); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* sslEnforcementGet({ projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/ssl-enforcement`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toBe("SSL is being enforced.\n"); + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "ssl-enforcement get", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md b/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md index 42de5cd8b7..9d0f59602d 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/ssl-enforcement/update/SIDE_EFFECTS.md @@ -2,32 +2,35 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | -------------------------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after the project ref is resolved (only if flag validation passes), via `Effect.ensuring` | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — including flag-validation failures. Not written if the gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves (only if flag validation passes), on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — including flag-validation failures. Not written if the gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------ | ------------ | ---------------------------------------- | -------------------------------------------------------------------- | -| `PUT` | `/v1/projects/{ref}/ssl-enforcement` | Bearer token | `{requestedConfig: {database: boolean}}` | `{currentConfig: {database: boolean}, appliedSuccessfully: boolean}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------ | ------------ | ---------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `PUT` | `/v1/projects/{ref}/ssl-enforcement` | Bearer token | `{requestedConfig: {database: boolean}}` | `{currentConfig: {database: boolean}, appliedSuccessfully: boolean}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref` → prompt) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes diff --git a/apps/cli/src/commands/ssl-enforcement/update/update.command.ts b/apps/cli/src/commands/ssl-enforcement/update/update.command.ts index 9894ae976f..9c8f7fa99b 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/update.command.ts +++ b/apps/cli/src/commands/ssl-enforcement/update/update.command.ts @@ -2,6 +2,10 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/output-formats.ts"; @@ -36,6 +40,26 @@ export type SslEnforcementUpdateFlags = CliCommand.Command.Config.Infer Effect.gen(function* () { // An invalid `-o` value must be rejected before the missing diff --git a/apps/cli/src/commands/ssl-enforcement/update/update.integration.test.ts b/apps/cli/src/commands/ssl-enforcement/update/update.integration.test.ts index 6be7fda23b..2ed195741a 100644 --- a/apps/cli/src/commands/ssl-enforcement/update/update.integration.test.ts +++ b/apps/cli/src/commands/ssl-enforcement/update/update.integration.test.ts @@ -1,21 +1,29 @@ import { BunServices } from "@effect/platform-bun"; import { type V1GetSslEnforcementConfigOutput } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Option, Path } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Path, Schema } from "effect"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; -import { mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { sslEnforcementUpdate } from "./update.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const SSL_ENFORCED: typeof V1GetSslEnforcementConfigOutput.Type = { currentConfig: { database: true }, appliedSuccessfully: true, @@ -128,6 +136,18 @@ describe("ssl-enforcement update integration", () => { }), ); expect(api.requests).toHaveLength(0); + expect(() => + assertPermissionDrift({ + command: "ssl-enforcement update", + activeFlags: { "enable-db-ssl-enforcement": false, "disable-db-ssl-enforcement": false }, + requests: [ + { + method: "PUT", + url: `https://api.supabase.com/v1/projects/${VALID_REF}/ssl-enforcement`, + }, + ], + }), + ).toThrow(/condition doesn't hold/); }).pipe(Effect.provide(layer)); }); @@ -158,6 +178,67 @@ describe("ssl-enforcement update integration", () => { expect(api.requests[0]?.body).toMatchObject({ requestedConfig: { database: true }, }); + assertPermissionDrift({ + command: "ssl-enforcement update", + activeFlags: { "enable-db-ssl-enforcement": true, "disable-db-ssl-enforcement": false }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists projects for the picker and fills the real linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + const path = new URL(request.url).pathname; + if (path === "/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (path === `/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if (path === `/v1/projects/${VALID_REF}/ssl-enforcement`) { + return Effect.succeed(jsonResponse(request, 200, SSL_ENFORCED)); + } + return Effect.succeed(jsonResponse(request, 404, { message: "unexpected request" })); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* sslEnforcementUpdate({ + projectRef: Option.none(), + enableDbSslEnforcement: true, + disableDbSslEnforcement: false, + }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `PUT https://api.supabase.com/v1/projects/${VALID_REF}/ssl-enforcement`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(api.requests[1]?.body).toMatchObject({ requestedConfig: { database: true } }); + expect(out.stdoutText).toBe("SSL is being enforced.\n"); + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "ssl-enforcement update", + activeFlags: { "enable-db-ssl-enforcement": true, "disable-db-ssl-enforcement": false }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -175,6 +256,12 @@ describe("ssl-enforcement update integration", () => { expect(api.requests[0]?.body).toMatchObject({ requestedConfig: { database: false }, }); + assertPermissionDrift({ + command: "ssl-enforcement update", + activeFlags: { "enable-db-ssl-enforcement": false, "disable-db-ssl-enforcement": true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }, ); diff --git a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md index 48ee9d87b6..56471c1137 100644 --- a/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/add/SIDE_EFFECTS.md @@ -8,27 +8,34 @@ | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | | `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | | `` | XML (UTF-8) | when `--metadata-file` is provided | | `` | JSON | when `--attribute-mapping-file` is provided | ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------ | -| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | -| `/supabase/.temp/linked-project.json` | JSON | best-effort after `--project-ref` resolves | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------------------------- | ------------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -| `POST` | `/v1/projects/{ref}/config/auth/sso/providers` | Bearer token | `{type, metadata_xml?, metadata_url?, domains?, attribute_mapping?, name_id_format?}` | `{id, saml?, domains?, created_at?, updated_at?}` (parsed loosely) | -| `GET` | `` | none | `Accept: application/xml`, 10s timeout | XML body (UTF-8) — validation when `--skip-url-validation` not set | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on 4xx | -| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate | - -Bypasses the typed Management API client for the POST so user-supplied keys inside -`attribute_mapping.keys.` (e.g. `default`) are preserved verbatim. +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------------------------- | ------------ | ------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `POST` | `/v1/projects/{ref}/config/auth/sso/providers` | Bearer token | `{type, metadata_xml?, metadata_url?, domains?, attribute_mapping?, name_id_format?}` | `{id, saml?, domains?, created_at?, updated_at?}` (typed schema) | +| `GET` | `` | none | `Accept: application/xml`, 10s timeout | XML body (UTF-8) — validation when `--skip-url-validation` not set | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | + +The POST goes through `api.executeRaw` with the typed `v1CreateASsoProvider` operation, which keeps the response body undecoded so structured output emits it as the API returned it; the typed provider schema decodes a copy for the table view. `--attribute-mapping-file` is validated +against the API's `attribute_mapping` schema before the request: a key the schema does not model +fails with `failed to parse attribute mapping: Expected no excess property at [...]` and nothing +is sent. A 2xx response whose body does not match the provider schema fails as +`SsoAddNetworkError` (API-response classification). Transport failures read +`failed to create sso provider: `. ## Environment Variables @@ -50,10 +57,10 @@ Bypasses the typed Management API client for the POST so user-supplied keys insi | `1` | `SsoAddRequiredFlagError` — the `--type`/`-t` token was consumed as another flag's value | | `1` | `SsoMutexFlagError` — `--metadata-file` and `--metadata-url` both set | | `1` | `SsoAddMetadataFileError` — metadata file unreadable, non-UTF-8, or metadata URL invalid/unreachable/non-UTF-8 | -| `1` | `SsoAddAttributeMappingFileError` — JSON file unreadable or malformed | +| `1` | `SsoAddAttributeMappingFileError` — JSON file unreadable, malformed, or not matching the schema | | `1` | `SsoAddSamlDisabledError` — 404 from POST | | `1` | `SsoAddUnexpectedStatusError` — other non-2xx | -| `1` | `SsoAddNetworkError` — transport-level failure | +| `1` | `SsoAddNetworkError` — transport failure or undecodable 2xx body | ## Telemetry Events Fired diff --git a/apps/cli/src/commands/sso/add/add.command.ts b/apps/cli/src/commands/sso/add/add.command.ts index 8d63451471..b7e6eae170 100644 --- a/apps/cli/src/commands/sso/add/add.command.ts +++ b/apps/cli/src/commands/sso/add/add.command.ts @@ -1,6 +1,11 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { stringSliceFlag } from "../../../command-internal/string-slice-flag.ts"; @@ -70,6 +75,25 @@ export const ssoAddCommand = Command.make("add", config).pipe( description: "Add a new SAML SSO provider", }, ]), + withPermissions( + compose( + { + operations: [{ operationId: "v1-create-a-sso-provider", kind: "required" }], + noApiEffectFlags: [ + "type", + "domains", + "metadata-file", + "metadata-url", + "skip-url-validation", + "attribute-mapping-file", + "name-id-format", + ], + }, + upgradeSuggestion, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => ssoAdd(flags).pipe( withCommandTelemetry({ diff --git a/apps/cli/src/commands/sso/add/add.handler.ts b/apps/cli/src/commands/sso/add/add.handler.ts index 6a1492d294..e65910c9d9 100644 --- a/apps/cli/src/commands/sso/add/add.handler.ts +++ b/apps/cli/src/commands/sso/add/add.handler.ts @@ -1,7 +1,8 @@ -import { Effect, Option, Predicate, Redacted, Stdio } from "effect"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import { operationDefinitions, type SupabaseApiError } from "@supabase/api/effect"; +import { Effect, Option, Predicate, type Redacted, Schema, Stdio, type Types } from "effect"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; @@ -12,23 +13,18 @@ import { scanArgvFlags, } from "../../../shared/cli/flag-groups.ts"; import { Output } from "../../../shared/output/output.service.ts"; -import { - encodeSortedJson, - encodeSortedJsonBody, -} from "../../../command-internal/output.encoders.ts"; +import { encodeSortedJson } from "../../../command-internal/output.encoders.ts"; import { encodeStructToml, encodeStructYaml, } from "../../../command-internal/struct-output.encoders.ts"; import { SSO_PROVIDER_RESPONSE_SHAPE } from "../sso.response-shape.ts"; -import { sanitizeErrorBody } from "../../../command-internal/http-errors.ts"; -import { requestWithAuth } from "../../../command-internal/raw-http.ts"; -import { resolveAccessToken } from "../../../command-internal/resolve-token.ts"; +import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { accessTokenForProfile } from "../../../auth/command-credentials.layer.ts"; import { missingAccessTokenMessage } from "../../../auth/access-token.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; import { TelemetryState } from "../../../telemetry/telemetry-state.service.ts"; -import { suggestUpgrade } from "../../../command-internal/upgrade-suggest.ts"; +import { gateResponse, suggestUpgrade } from "../../../command-internal/upgrade-suggest.ts"; import { argvBoolValue, argvEnumValue, @@ -50,14 +46,34 @@ import { SsoAccessTokenError, SsoTomlEncodeError, } from "../sso.errors.ts"; -import { renderSingleProvider, toSsoProviderView } from "../sso.format.ts"; +import { renderSingleProvider } from "../sso.format.ts"; import { validateMetadataUrl } from "../sso.metadata-url.ts"; -import { SSO_NAME_ID_FORMATS, readAttributeMappingFile, readMetadataFile } from "../sso.saml.ts"; +import { + SSO_NAME_ID_FORMATS, + readAttributeMappingFile, + readMetadataFile, + toNameIdFormat, +} from "../sso.saml.ts"; import type { SsoAddFlags } from "./add.command.ts"; const SAML_DISABLED_MESSAGE = "SAML 2.0 support is not enabled for this project. Please enable it through the dashboard"; +type SsoAddBody = Types.Mutable< + Omit, "ref"> +>; + +const decodeProvider = Schema.decodeUnknownEffect( + operationDefinitions.v1CreateASsoProvider.outputSchema, +); + +const mapStatusOrNetwork = mapHttpError({ + networkError: SsoAddNetworkError, + statusError: SsoAddUnexpectedStatusError, + networkMessage: (cause) => `failed to create sso provider: ${cause}`, + statusMessage: (_status, body) => `Unexpected error adding identity provider: ${body}`, +}); + const readMetadata = readMetadataFile({ openError: (args) => new SsoAddMetadataFileError(args), nonUtf8Error: (args) => @@ -68,6 +84,43 @@ const readAttributeMapping = readAttributeMappingFile({ openError: (args) => new SsoAddAttributeMappingFileError(args), }); +type TokenForAux = Effect.Effect> | undefined, never, R>; + +const handleAddError = ( + ref: string, + apiUrl: string, + tokenForAux: TokenForAux, + cause: SupabaseApiError, +) => + mapStatusOrNetwork(cause).pipe( + Effect.catchTag("SsoAddUnexpectedStatusError", (mapped) => + Effect.gen(function* () { + const upgradeSuggested = yield* suggestUpgrade({ + projectRef: ref, + featureKey: "auth.saml_2", + statusCode: mapped.status, + response: gateResponse(cause), + apiUrl, + ...(yield* Effect.map(tokenForAux, (token) => + token !== undefined ? { accessToken: token } : {}, + )), + }); + if (mapped.status === 404) { + return yield* new SsoAddSamlDisabledError({ + message: SAML_DISABLED_MESSAGE, + upgradeSuggested, + }); + } + return yield* new SsoAddUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }); + }), + ), + ); + const SSO_ADD_COMMAND_PATH = ["sso", "add"] as const; // Declaration order sets the mutex error message's bracket order. @@ -93,7 +146,7 @@ const SSO_ADD_SCAN_SPEC = { export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { const output = yield* Output; const outputFlag = yield* OutputFlag; - const httpClient = yield* HttpClient.HttpClient; + const api = yield* CommandPlatformApi; const cliSettings = yield* CommandSettings; const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; @@ -198,15 +251,13 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { }); yield* Effect.gen(function* () { - // Posted as raw JSON, not the generated schema, so unlisted - // `attribute_mapping.keys.` fields (e.g. `default`) survive. - const body: Record = { - type: flags.type, + const body: SsoAddBody = { + type: "saml", }; if (Option.isSome(metadataFile)) { const xml = yield* readMetadata(metadataFile.value); - body["metadata_xml"] = xml; + body.metadata_xml = xml; } else if (Option.isSome(metadataUrl)) { if (!skipUrlValidation) { yield* validateMetadataUrl(metadataUrl.value).pipe( @@ -220,104 +271,72 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { ), ); } - body["metadata_url"] = metadataUrl.value; + body.metadata_url = metadataUrl.value; } if (Option.isSome(attributeMappingFile)) { const mapping = yield* readAttributeMapping(attributeMappingFile.value); - body["attribute_mapping"] = mapping; + body.attribute_mapping = mapping; } if (domains.length > 0) { - body["domains"] = [...domains]; + body.domains = [...domains]; } if (Option.isSome(nameIdFormat)) { - body["name_id_format"] = nameIdFormat.value; + const format = toNameIdFormat(nameIdFormat.value); + if (format !== undefined) body.name_id_format = format; } - const creating = - output.format === "text" ? yield* output.task("Adding SSO provider...") : undefined; - - const tokenOpt = - reconciledTokenCached !== undefined - ? yield* Effect.filterOrFail( + const reconciledToken = + reconciledTokenCached === undefined + ? undefined + : (yield* Effect.filterOrFail( reconciledTokenCached, Option.isSome, () => new SsoAccessTokenError({ message: missingAccessTokenMessage() }), - ) - : yield* resolveAccessToken; - - // `bearerToken(Redacted)` keeps the Authorization header's redaction - // marker, so any future debug serialization of the request stays - // opaque about the token. - const request = requestWithAuth( - HttpClientRequest.post(`${apiUrl}/v1/projects/${ref}/config/auth/sso/providers`), - tokenOpt, - cliSettings.userAgent, - ).pipe( - // Alphabetical key order so the cli-e2e replay server's - // string-compare body match succeeds. - HttpClientRequest.bodyText(encodeSortedJsonBody(body), "application/json"), - ); + )).value; + + const creating = + output.format === "text" ? yield* output.task("Adding SSO provider...") : undefined; - const response = yield* httpClient.execute(request).pipe( + // `executeRaw` keeps the response body intact so structured output emits it as the API + // returned it; the typed schema decodes a copy for the table view. + const { rawBody, provider } = yield* Effect.gen(function* () { + const response = yield* api + .executeRaw( + operationDefinitions.v1CreateASsoProvider, + { ref, ...body }, + { + retry: { requestTimeoutMs: Number.POSITIVE_INFINITY }, + ...(reconciledToken === undefined + ? {} + : { baseUrl: apiUrl, accessToken: reconciledToken }), + }, + ) + .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); + const rawBody: unknown = yield* response.json; + return { rawBody, provider: yield* decodeProvider(rawBody) }; + }).pipe( + Effect.catch((cause) => handleAddError(ref, apiUrl, reconciledTokenForAux, cause)), Effect.tapError(() => creating?.fail() ?? Effect.void), - Effect.mapError( - (cause) => - new SsoAddNetworkError({ - message: `failed to create sso provider: ${String(cause)}`, - }), - ), ); - - if (response.status !== 201) { - const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - // Same cap + control-character sanitization as the typed-client - // error mapper (`mapHttpError`), so this raw-HTTP path doesn't skip it. - const bodyText = sanitizeErrorBody(rawBody); - const upgradeSuggested = yield* suggestUpgrade({ - projectRef: ref, - featureKey: "auth.saml_2", - statusCode: response.status, - response, - apiUrl, - ...(yield* Effect.map(reconciledTokenForAux, (token) => - token !== undefined ? { accessToken: token } : {}, - )), - }); - yield* creating?.fail() ?? Effect.void; - if (response.status === 404) { - return yield* new SsoAddSamlDisabledError({ - message: SAML_DISABLED_MESSAGE, - upgradeSuggested, - }); - } - return yield* new SsoAddUnexpectedStatusError({ - status: response.status, - body: bodyText, - message: `Unexpected error adding identity provider: ${bodyText}`, - upgradeSuggested, - }); - } - - const parsedJson = yield* response.json.pipe(Effect.orElseSucceed((): unknown => ({}))); yield* creating?.clear ?? Effect.void; const outputFlagFormat = Option.getOrUndefined(outputFlag); if (outputFlagFormat === "json") { - yield* output.raw(encodeSortedJson(parsedJson)); + yield* output.raw(encodeSortedJson(rawBody)); return; } if (outputFlagFormat === "yaml") { - yield* output.raw(encodeStructYaml(parsedJson, SSO_PROVIDER_RESPONSE_SHAPE)); + yield* output.raw(encodeStructYaml(rawBody, SSO_PROVIDER_RESPONSE_SHAPE)); return; } if (outputFlagFormat === "toml") { // Same TOML-encode-failure pattern as list/show. const toml = yield* Effect.try({ - try: () => encodeStructToml(parsedJson, SSO_PROVIDER_RESPONSE_SHAPE), + try: () => encodeStructToml(rawBody, SSO_PROVIDER_RESPONSE_SHAPE), catch: (cause) => new SsoTomlEncodeError({ message: `failed to output toml: ${cause instanceof Error ? cause.message : String(cause)}`, @@ -334,12 +353,12 @@ export const ssoAdd = Effect.fn("sso.add")(function* (flags: SsoAddFlags) { if (output.format === "json" || output.format === "stream-json") { yield* output.success( "", - Predicate.isObjectOrArray(parsedJson) ? { ...parsedJson } : { value: parsedJson }, + Predicate.isObjectOrArray(rawBody) ? { ...rawBody } : { value: rawBody }, ); return; } - yield* output.raw(renderSingleProvider(toSsoProviderView(parsedJson))); + yield* output.raw(renderSingleProvider(provider)); }).pipe( // Linked-project cache fill GETs `/v1/projects/{ref}` through the // reconciled host, never the config layer's. diff --git a/apps/cli/src/commands/sso/add/add.integration.test.ts b/apps/cli/src/commands/sso/add/add.integration.test.ts index d04cc43123..22599b7e82 100644 --- a/apps/cli/src/commands/sso/add/add.integration.test.ts +++ b/apps/cli/src/commands/sso/add/add.integration.test.ts @@ -1,10 +1,21 @@ import { BunServices } from "@effect/platform-bun"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, FileSystem, Layer, Option, Path, Predicate, Stdio } from "effect"; +import { + Cause, + Effect, + Exit, + FileSystem, + Layer, + Option, + Path, + Predicate, + Schema, + Stdio, +} from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, DEFAULT_API_URL, @@ -15,6 +26,11 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { ProfileFlag } from "../../../command-internal/global-flags.ts"; import { EventUpgradeSuggested } from "../../../shared/telemetry/event-catalog.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; @@ -30,6 +46,8 @@ const RESPONSE_PROVIDER = { domains: [{ domain: "example.com" }], }; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-sso-add-int-"); interface SetupOpts { @@ -215,6 +233,58 @@ describe("sso add integration", () => { expect(req).toBeDefined(); expect(req?.url).toContain(`/v1/projects/${VALID_REF}/config/auth/sso/providers`); expect(req?.body).toHaveProperty("type", "saml"); + assertPermissionDrift({ command: "sso add", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the projects for the picker and fills the linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.method === "GET" && request.url === `${DEFAULT_API_URL}/v1/projects`) { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if ( + request.method === "GET" && + request.url === `${DEFAULT_API_URL}/v1/projects/${VALID_REF}` + ) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, 201, RESPONSE_PROVIDER)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }), + Stdio.layerTest({ args: Effect.succeed(["sso", "add", "--type", "saml"]) }), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* ssoAdd(defaultFlags); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${DEFAULT_API_URL}/v1/projects`, + `POST ${DEFAULT_API_URL}/v1/projects/${VALID_REF}/config/auth/sso/providers`, + `GET ${DEFAULT_API_URL}/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain(RESPONSE_PROVIDER.id); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "sso add", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -870,6 +940,17 @@ describe("sso add integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("Go --output=json keeps response keys outside the OpenAPI schema", () => { + const { layer, out } = setup({ + outputFlag: "json", + body: { ...RESPONSE_PROVIDER, resource_id: "res-123" }, + }); + return Effect.gen(function* () { + yield* ssoAdd(defaultFlags); + expect(out.stdoutText).toMatch(/"resource_id":\s*"res-123"/); + }).pipe(Effect.provide(layer)); + }); + it.effect("TS --output-format=json emits success", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { @@ -890,10 +971,16 @@ describe("sso add integration", () => { }); it.effect("fires cli_upgrade_suggested on 404 when entitlement is gated", () => { - const { layer, analytics } = setup({ status: 404, body: {}, upgradeGate: "gated" }); + const { layer, analytics, api } = setup({ status: 404, body: {}, upgradeGate: "gated" }); return Effect.gen(function* () { yield* Effect.exit(ssoAdd(defaultFlags)); expect(analytics.captured.some((c) => c.event === EventUpgradeSuggested)).toBe(true); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `POST ${DEFAULT_API_URL}/v1/projects/${VALID_REF}/config/auth/sso/providers`, + `GET ${DEFAULT_API_URL}/v1/projects/${VALID_REF}`, + `GET ${DEFAULT_API_URL}/v1/organizations/acme/entitlements`, + ]); + assertPermissionDrift({ command: "sso add", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -905,7 +992,7 @@ describe("sso add integration", () => { if (Exit.isFailure(exit)) { const dump = Cause.pretty(exit.cause); expect(dump).toContain("SsoAddUnexpectedStatusError"); - expect(dump).toContain("Unexpected error adding identity provider"); + expect(dump).toContain('Unexpected error adding identity provider: {"error":"boom"}'); } }).pipe(Effect.provide(layer)); }); @@ -1026,6 +1113,52 @@ describe("sso add integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("an attribute mapping with an unknown key fails before the POST", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const path = pathService.join(tempRoot.current, "unknown-key.json"); + yield* fs.writeFileString(path, '{"keys":{"email":{"nam":"email"}}}'); + const flags = { ...defaultFlags, attributeMappingFile: Option.some(path) }; + const { layer, api } = setup({ cliArgs: cliArgsFor(flags) }); + return yield* Effect.gen(function* () { + const exit = yield* Effect.exit(ssoAdd(flags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = Cause.pretty(exit.cause); + expect(dump).toContain("SsoAddAttributeMappingFileError"); + expect(dump).toContain("failed to parse attribute mapping: Expected no excess property"); + } + expect(api.requests).toHaveLength(0); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("a transport failure surfaces as a network error naming the operation", () => { + const { layer } = setup({ network: "fail" }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(ssoAdd(defaultFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = Cause.pretty(exit.cause); + expect(dump).toContain("SsoAddNetworkError"); + expect(dump).toContain("failed to create sso provider:"); + } + }).pipe(Effect.provide(layer)); + }); + + it.effect("a 201 body that fails the provider schema surfaces as an API response error", () => { + const { layer } = setup({ body: { saml: {} } }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(ssoAdd(defaultFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const classified = classifyCliCauseActionability(exit.cause); + expect(classified.error_fingerprint).toBe("tag:SsoAddNetworkError:api_response"); + } + }).pipe(Effect.provide(layer)); + }); + const writeProfileYaml = Effect.fnUntraced(function* (name: string, apiUrl: string) { const fs = yield* FileSystem.FileSystem; const pathService = yield* Path.Path; diff --git a/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md index 41dff802ca..c4216e9169 100644 --- a/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/info/SIDE_EFFECTS.md @@ -2,29 +2,40 @@ ## Files Read -| Path | Format | When | -| ---------------------------------------------- | ------ | -------------------------------------------------------------- | -| `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | --------------------------------------------------------------------------------------------- | +| keyring `"Supabase CLI"` / `` | OS keychain | when `SUPABASE_ACCESS_TOKEN` unset and keyring available; account = `CommandSettings.profile` | +| keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | +| `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | +| `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | -`info` makes **no** API call, so no access-token lookup occurs. +`info` makes no Management API call of its own, but the Management API client is built eagerly, so an +access token is required: a missing one fails before the handler runs. ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------ | -| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | -| `/supabase/.temp/linked-project.json` | JSON | best-effort after `--project-ref` resolves | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | ## API Routes -`info` derives all three URLs locally from the project ref. No Management API -calls are made. +`info` derives all three URLs locally from the project ref and makes no Management API call of its +own. The ref resolution and the cache fill still call the API: + +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables -| Variable | Purpose | Required? | -| ------------------ | -------------------------------------------------- | --------------------------- | -| `SUPABASE_PROFILE` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | +| Variable | Purpose | Required? | +| ----------------------- | ---------------------------------------------------- | ------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring → `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | profile selector (built-in name or YAML file path) | no (defaults to `supabase`) | ## Exit Codes diff --git a/apps/cli/src/commands/sso/info/info.command.ts b/apps/cli/src/commands/sso/info/info.command.ts index f7ef7b1d3c..9cb148d261 100644 --- a/apps/cli/src/commands/sso/info/info.command.ts +++ b/apps/cli/src/commands/sso/info/info.command.ts @@ -1,6 +1,10 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -25,6 +29,7 @@ export const ssoInfoCommand = Command.make("info", config).pipe( description: "Get SSO info for a project", }, ]), + withPermissions(compose({ operations: [] }, projectRefPicker, linkedProjectCache)), Command.withHandler((flags) => ssoInfo(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), diff --git a/apps/cli/src/commands/sso/info/info.integration.test.ts b/apps/cli/src/commands/sso/info/info.integration.test.ts index 827dfd1d8f..6238db8b3f 100644 --- a/apps/cli/src/commands/sso/info/info.integration.test.ts +++ b/apps/cli/src/commands/sso/info/info.integration.test.ts @@ -1,18 +1,26 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Option } from "effect"; +import { Effect, FileSystem, Option, Schema } from "effect"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, VALID_REF, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { ssoInfo } from "./info.handler.ts"; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-sso-info-int-"); interface SetupOpts { @@ -143,11 +151,52 @@ describe("sso info integration", () => { }).pipe(Effect.provide(layer)); }); - it.effect("does NOT make any API call", () => { + it.effect("makes no Management API call of its own", () => { const { layer, api } = setup(); return Effect.gen(function* () { yield* ssoInfo({ projectRef: Option.none() }); expect(api.requests).toHaveLength(0); }).pipe(Effect.provide(layer)); }); + + it.effect("lists the picker's projects and fills the real cache, with no call of its own", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, 404, {})); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* ssoInfo({ projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain(expectedAcsUrl); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "sso info", requests: api.requests }); + }).pipe(Effect.provide(layer)); + }); }); diff --git a/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md index 1db960dcc1..ca113c3ce8 100644 --- a/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/list/SIDE_EFFECTS.md @@ -8,21 +8,24 @@ | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | | `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------ | -| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | -| `/supabase/.temp/linked-project.json` | JSON | best-effort after `--project-ref` resolves | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------------------------- | ------------ | ------------ | -------------------------------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/config/auth/sso/providers` | Bearer token | none | `{items: [{id, saml?, domains?, created_at?, updated_at?}]}` | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on 4xx | -| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate side-call on 4xx | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/config/auth/sso/providers` | Bearer token | none | `{items: [{id, saml?, domains?, created_at?, updated_at?}]}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables diff --git a/apps/cli/src/commands/sso/list/list.command.ts b/apps/cli/src/commands/sso/list/list.command.ts index a3cd242bae..13905e7e16 100644 --- a/apps/cli/src/commands/sso/list/list.command.ts +++ b/apps/cli/src/commands/sso/list/list.command.ts @@ -1,6 +1,11 @@ import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -23,6 +28,14 @@ export const ssoListCommand = Command.make("list", config).pipe( description: "List all SSO providers for a project", }, ]), + withPermissions( + compose( + { operations: [{ operationId: "v1-list-all-sso-provider", kind: "required" }] }, + upgradeSuggestion, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => ssoList(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), diff --git a/apps/cli/src/commands/sso/list/list.integration.test.ts b/apps/cli/src/commands/sso/list/list.integration.test.ts index 3bbb6dfb21..057989037d 100644 --- a/apps/cli/src/commands/sso/list/list.integration.test.ts +++ b/apps/cli/src/commands/sso/list/list.integration.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option, Schema } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, VALID_REF, @@ -12,6 +12,11 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { EventUpgradeSuggested } from "../../../shared/telemetry/event-catalog.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; @@ -37,6 +42,8 @@ const PROVIDER_ITEM = { updated_at: "2023-03-28T13:50:14.464Z", }; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-sso-list-int-"); interface SetupOpts { @@ -45,6 +52,7 @@ interface SetupOpts { status?: number; body?: unknown; network?: "fail"; + projectId?: Option.Option; // Configures the upgrade-gate side-call branches: // "gated" → entitlement says auth.saml_2 has_access=false // "notGated" → entitlement says auth.saml_2 has_access=true @@ -124,7 +132,10 @@ function setup(opts: SetupOpts = {}) { }, }); - const cliSettings = mockCommandSettings({ workdir: tempRoot.current }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: opts.projectId, + }); const layer = buildTestRuntime({ out, api, @@ -326,10 +337,16 @@ describe("sso list integration", () => { }); it.effect("does NOT fire cli_upgrade_suggested when entitlement is not gated", () => { - const { layer, analytics } = setup({ status: 404, body: {}, upgradeGate: "notGated" }); + const { layer, analytics, api } = setup({ status: 404, body: {}, upgradeGate: "notGated" }); return Effect.gen(function* () { yield* Effect.exit(ssoList({ projectRef: Option.none() })); expect(analytics.captured.some((c) => c.event === EventUpgradeSuggested)).toBe(false); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/config/auth/sso/providers`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + "GET https://api.supabase.com/v1/organizations/acme/entitlements", + ]); + assertPermissionDrift({ command: "sso list", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -377,9 +394,77 @@ describe("sso list integration", () => { yield* ssoList({ projectRef: Option.none() }); const ssoRequest = api.requests.find((r) => r.url.endsWith(`/config/auth/sso/providers`)); expect(ssoRequest?.method).toBe("GET"); + assertPermissionDrift({ command: "sso list", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("ignores an empty --project-ref and resolves the saved ref without the picker", () => { + const { layer, api } = setup({ projectId: Option.none() }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* fs.makeDirectory(`${tempRoot.current}/supabase/.temp`, { recursive: true }); + yield* fs.writeFileString(`${tempRoot.current}/supabase/.temp/project-ref`, VALID_REF); + yield* ssoList({ projectRef: Option.some("") }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/config/auth/sso/providers`, + ]); + assertPermissionDrift({ + command: "sso list", + activeFlags: { "project-ref": "" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); + it.effect( + "lists the picker's projects, the providers and fills the real linked-project cache", + () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, 200, { items: [PROVIDER_ITEM] })); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* ssoList({ projectRef: Option.none() }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/config/auth/sso/providers`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain("0b0d48f6-878b-4190-88d7-2ca33ed800bc"); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "sso list", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("flushes telemetry + linked-project cache on success", () => { const { layer, telemetry, cache } = setup(); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md index 89695b2ed1..a360cdfa51 100644 --- a/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/remove/SIDE_EFFECTS.md @@ -8,21 +8,24 @@ | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | | `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------ | -| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | -| `/supabase/.temp/linked-project.json` | JSON | best-effort after `--project-ref` resolves | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ------------------------------------------------------------ | ------------ | ------------ | --------------------------------------------------------- | -| `DELETE` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on 4xx | -| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------------------------------ | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `DELETE` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables diff --git a/apps/cli/src/commands/sso/remove/remove.command.ts b/apps/cli/src/commands/sso/remove/remove.command.ts index 49d70dfb3e..95a41eb24e 100644 --- a/apps/cli/src/commands/sso/remove/remove.command.ts +++ b/apps/cli/src/commands/sso/remove/remove.command.ts @@ -1,6 +1,11 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -29,6 +34,14 @@ export const ssoRemoveCommand = Command.make("remove", config).pipe( description: "Remove an SSO provider by ID", }, ]), + withPermissions( + compose( + { operations: [{ operationId: "v1-delete-a-sso-provider", kind: "required" }] }, + upgradeSuggestion, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => ssoRemove(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), diff --git a/apps/cli/src/commands/sso/remove/remove.integration.test.ts b/apps/cli/src/commands/sso/remove/remove.integration.test.ts index 3f47e2c0b6..cf48e61754 100644 --- a/apps/cli/src/commands/sso/remove/remove.integration.test.ts +++ b/apps/cli/src/commands/sso/remove/remove.integration.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, VALID_REF, @@ -12,6 +12,11 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { EventUpgradeSuggested } from "../../../shared/telemetry/event-catalog.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; import { ssoRemove } from "./remove.handler.ts"; @@ -24,6 +29,8 @@ const PROVIDER = { domains: [{ domain: "example.com" }], }; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-sso-remove-int-"); interface SetupOpts { @@ -144,6 +151,7 @@ describe("sso remove integration", () => { `/v1/projects/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`, ); expect(out.stdoutText).toContain(VALID_PROVIDER_ID); + assertPermissionDrift({ command: "sso remove", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -176,10 +184,16 @@ describe("sso remove integration", () => { }); it.effect("fires cli_upgrade_suggested when gated on 4xx", () => { - const { layer, analytics } = setup({ status: 404, body: {}, upgradeGate: "gated" }); + const { layer, analytics, api } = setup({ status: 404, body: {}, upgradeGate: "gated" }); return Effect.gen(function* () { yield* Effect.exit(ssoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID })); expect(analytics.captured.some((c) => c.event === EventUpgradeSuggested)).toBe(true); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + "GET https://api.supabase.com/v1/organizations/acme/entitlements", + ]); + assertPermissionDrift({ command: "sso remove", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -233,6 +247,48 @@ describe("sso remove integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("lists the picker's projects, removes the provider and fills the real cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, 200, PROVIDER)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* ssoRemove({ projectRef: Option.none(), providerId: VALID_PROVIDER_ID }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `DELETE https://api.supabase.com/v1/projects/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain(VALID_PROVIDER_ID); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "sso remove", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + it.effect("flushes telemetry + linked-project cache on success", () => { const { layer, telemetry, cache } = setup(); return Effect.gen(function* () { diff --git a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md index 44764120a3..b0cd60ca71 100644 --- a/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/show/SIDE_EFFECTS.md @@ -8,19 +8,22 @@ | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | | `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------ | -| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | -| `/supabase/.temp/linked-project.json` | JSON | best-effort after `--project-ref` resolves | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------------------------ | ------------ | ------------ | ------------------------------------------------- | -| `GET` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------------------------ | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | Note: Unlike `list`/`add`/`update`/`remove`, `show` does **not** make upgrade-gate side-calls. diff --git a/apps/cli/src/commands/sso/show/show.command.ts b/apps/cli/src/commands/sso/show/show.command.ts index 678d1a5ecb..58d90a54f8 100644 --- a/apps/cli/src/commands/sso/show/show.command.ts +++ b/apps/cli/src/commands/sso/show/show.command.ts @@ -1,6 +1,10 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; @@ -33,6 +37,16 @@ export const ssoShowCommand = Command.make("show", config).pipe( description: "Show SSO provider details", }, ]), + withPermissions( + compose( + { + operations: [{ operationId: "v1-get-a-sso-provider", kind: "required" }], + noApiEffectFlags: ["metadata"], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => ssoShow(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"] }), diff --git a/apps/cli/src/commands/sso/show/show.integration.test.ts b/apps/cli/src/commands/sso/show/show.integration.test.ts index cf2f694f7d..0c623b2f89 100644 --- a/apps/cli/src/commands/sso/show/show.integration.test.ts +++ b/apps/cli/src/commands/sso/show/show.integration.test.ts @@ -1,16 +1,22 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockAnalytics, mockOutput } from "../../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, VALID_REF, + jsonResponse, mockCommandSettings, mockLinkedProjectCacheTracked, mockCommandPlatformApi, mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { EventUpgradeSuggested } from "../../../shared/telemetry/event-catalog.ts"; import { ssoShow } from "./show.handler.ts"; @@ -28,6 +34,8 @@ const PROVIDER = { updated_at: "2023-03-28T13:50:14.464Z", }; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-sso-show-int-"); interface SetupOpts { @@ -261,7 +269,7 @@ UpdatedAt = "2023-03-28T13:50:14.464Z" }); it.effect("--metadata short-circuits and prints raw XML", () => { - const { layer, out } = setup(); + const { layer, out, api } = setup(); return Effect.gen(function* () { yield* ssoShow({ projectRef: Option.none(), @@ -269,6 +277,15 @@ UpdatedAt = "2023-03-28T13:50:14.464Z" metadata: true, }); expect(out.stdoutText).toBe('\n'); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`, + ]); + assertPermissionDrift({ + command: "sso show", + activeFlags: { metadata: true }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -324,6 +341,53 @@ UpdatedAt = "2023-03-28T13:50:14.464Z" expect(req?.url).toContain( `/v1/projects/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`, ); + assertPermissionDrift({ command: "sso show", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the picker's projects, shows the provider and fills the real cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.url === "https://api.supabase.com/v1/projects") { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.url === `https://api.supabase.com/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, 200, PROVIDER)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* ssoShow({ + projectRef: Option.none(), + providerId: VALID_PROVIDER_ID, + metadata: false, + }); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + "GET https://api.supabase.com/v1/projects", + `GET https://api.supabase.com/v1/projects/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`, + `GET https://api.supabase.com/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain(VALID_PROVIDER_ID); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "sso show", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/cli/src/commands/sso/sso.errors.ts b/apps/cli/src/commands/sso/sso.errors.ts index 56d0a98dff..42004fa303 100644 --- a/apps/cli/src/commands/sso/sso.errors.ts +++ b/apps/cli/src/commands/sso/sso.errors.ts @@ -93,9 +93,12 @@ export class SsoListUnexpectedStatusError extends Data.TaggedError("SsoListUnexp export class SsoAddNetworkError extends Data.TaggedError("SsoAddNetworkError")<{ readonly message: string; + readonly decode?: boolean; }> { get [ErrorActionabilityId](): CliErrorActionabilityDeclaration { - return actionability.externalNetwork; + return this.decode === true + ? { ...actionability.apiStatus, fingerprint_suffix: "api_response" } + : actionability.externalNetwork; } } diff --git a/apps/cli/src/commands/sso/sso.format.ts b/apps/cli/src/commands/sso/sso.format.ts index d5cba11cb0..19de889944 100644 --- a/apps/cli/src/commands/sso/sso.format.ts +++ b/apps/cli/src/commands/sso/sso.format.ts @@ -8,10 +8,8 @@ export const quoteSsoString = Schema.encodeSync(Schema.fromJsonString(Schema.Str const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** - * Permissive shape that covers both the typed-client output - * (`V1GetASsoProviderOutput` / list items) and arbitrary JSON returned by the - * raw POST/PUT path. `attribute_mapping` is left as `unknown` so we can render - * provider responses that carry user-defined keys. + * Provider shape rendered by list/show/add/update/remove, structurally + * compatible with the typed-client provider outputs. */ export interface SsoProviderView { readonly id: string; @@ -31,61 +29,6 @@ export interface SsoProviderView { readonly updated_at?: string; } -/** - * Defensive extraction for the raw-HTTP add/update path. The response is - * untyped (we bypass the generated schema to preserve `attribute_mapping.keys..default`), - * so the formatter coerces an arbitrary object into the provider view shape - * without throwing on missing fields. - */ -export function toSsoProviderView(value: unknown): SsoProviderView { - const root = fieldsOf(value); - if (root === undefined) { - return { id: "" }; - } - const saml = fieldsOf(root["saml"]); - - const domainsRaw = root["domains"]; - const domains = Array.isArray(domainsRaw) - ? domainsRaw.flatMap((item) => { - const d = fieldsOf(item); - return d === undefined - ? [] - : [ - { - domain: typeof d["domain"] === "string" ? d["domain"] : undefined, - created_at: typeof d["created_at"] === "string" ? d["created_at"] : undefined, - updated_at: typeof d["updated_at"] === "string" ? d["updated_at"] : undefined, - }, - ]; - }) - : undefined; - - return { - id: typeof root["id"] === "string" ? root["id"] : "", - saml: - saml === undefined - ? undefined - : { - entity_id: typeof saml["entity_id"] === "string" ? saml["entity_id"] : undefined, - metadata_url: - typeof saml["metadata_url"] === "string" ? saml["metadata_url"] : undefined, - metadata_xml: - typeof saml["metadata_xml"] === "string" ? saml["metadata_xml"] : undefined, - name_id_format: - typeof saml["name_id_format"] === "string" ? saml["name_id_format"] : undefined, - attribute_mapping: saml["attribute_mapping"], - }, - domains, - created_at: typeof root["created_at"] === "string" ? root["created_at"] : undefined, - updated_at: typeof root["updated_at"] === "string" ? root["updated_at"] : undefined, - }; -} - -function fieldsOf(value: unknown): { readonly [key: string]: unknown } | undefined { - if (!Predicate.isObjectOrArray(value)) return undefined; - return Array.isArray(value) ? {} : value; -} - /** * Validates a positional provider-id argument as a canonical UUID. * Failure message double-quotes the raw input via JSON.stringify. diff --git a/apps/cli/src/commands/sso/sso.format.unit.test.ts b/apps/cli/src/commands/sso/sso.format.unit.test.ts index f2c10c1501..7bec70c82f 100644 --- a/apps/cli/src/commands/sso/sso.format.unit.test.ts +++ b/apps/cli/src/commands/sso/sso.format.unit.test.ts @@ -15,7 +15,6 @@ import { renderInfoMarkdown, renderListProviders, renderSingleProvider, - toSsoProviderView, validateUuid, } from "./sso.format.ts"; @@ -243,18 +242,3 @@ describe("formatSsoMetadataXml (xmlfmt parity port)", () => { expect(formatSsoMetadataXml(saml, " ", " ")).toBe(expected); }); }); - -describe("toSsoProviderView coercion", () => { - it("returns an empty view for non-object inputs", () => { - expect(toSsoProviderView(null).id).toBe(""); - expect(toSsoProviderView("string").id).toBe(""); - }); - - it("preserves arbitrary attribute_mapping data (incl. user keys)", () => { - const view = toSsoProviderView({ - id: "abc", - saml: { attribute_mapping: { keys: { a: { default: 3 } } } }, - }); - expect(view.saml?.attribute_mapping).toEqual({ keys: { a: { default: 3 } } }); - }); -}); diff --git a/apps/cli/src/commands/sso/sso.saml.ts b/apps/cli/src/commands/sso/sso.saml.ts index 1f76dd32ec..ccd673a550 100644 --- a/apps/cli/src/commands/sso/sso.saml.ts +++ b/apps/cli/src/commands/sso/sso.saml.ts @@ -1,4 +1,5 @@ -import { Effect, FileSystem, Predicate } from "effect"; +import { operationDefinitions } from "@supabase/api/effect"; +import { Effect, FileSystem, Predicate, Schema, SchemaIssue } from "effect"; import type { PlatformError } from "effect/PlatformError"; import { quoteSsoString } from "./sso.format.ts"; @@ -16,6 +17,8 @@ function fileErrorReason(cause: PlatformError): SsoFileErrorReason { return "other"; } +export type SsoNameIdFormat = (typeof SSO_NAME_ID_FORMATS)[number]; + /** * The `--name-id-format` value set, shared by `sso add` and `sso update`. * Order matters: it drives the CLI help text and is joined verbatim into @@ -29,6 +32,10 @@ export const SSO_NAME_ID_FORMATS = [ "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", ] as const; +/** Narrows a pflag-validated `--name-id-format` value to the API's literal union. */ +export const toNameIdFormat = (value: string): SsoNameIdFormat | undefined => + SSO_NAME_ID_FORMATS.find((format) => format === value); + /** * Validates that raw bytes decode as strict UTF-8. Using * `TextDecoder("utf-8", { fatal: true })` makes any malformed surrogate or @@ -82,10 +89,16 @@ export const readMetadataFile = return new TextDecoder("utf-8").decode(bytes); }); +const attributeMappingSchema = + operationDefinitions.v1CreateASsoProvider.inputSchema.fields.attribute_mapping; +const decodeAttributeMapping = Schema.decodeUnknownEffect(attributeMappingSchema); +const formatSchemaIssue = SchemaIssue.makeFormatterDefault(); + +export type SsoAttributeMapping = Schema.Schema.Type; + /** - * Reads an attribute mapping JSON file. Returns the parsed value as - * `unknown` so the raw POST/PUT payload preserves user-defined keys like - * `default` that aren't in the generated `attribute_mapping` schema. + * Reads an attribute mapping JSON file and validates it against the Management + * API `attribute_mapping` schema; keys the API does not model are rejected. */ export const readAttributeMappingFile = (factory: { @@ -94,7 +107,7 @@ export const readAttributeMappingFile = readonly reason: SsoFileErrorReason; }) => E; }) => - (path: string): Effect.Effect => + (path: string): Effect.Effect => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; const content = yield* fs.readFileString(path).pipe( @@ -114,5 +127,15 @@ export const readAttributeMappingFile = reason: "invalid_content", }), }); - return parsed; + return yield* decodeAttributeMapping(parsed, { + onExcessProperty: "error", + errors: "all", + }).pipe( + Effect.mapError((cause) => + factory.openError({ + message: `failed to parse attribute mapping: ${formatSchemaIssue(cause.issue).replace(/\s*\n\s*/g, " ")}`, + reason: "invalid_content", + }), + ), + ); }); diff --git a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md index 4145cfed08..81fff28a70 100644 --- a/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/sso/update/SIDE_EFFECTS.md @@ -8,29 +8,37 @@ | keyring `"Supabase CLI"` / `access-token` | OS keychain | legacy-key fallback when the profile-keyed lookup misses | | `~/.supabase/access-token` | plain text (token string) | last-resort fallback after env + keyring miss | | `/supabase/.temp/linked-project.json` | JSON | always — `linkedProjectCache` reads to decide whether to write | +| `/supabase/.temp/project-ref` | plain text | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are unset | | `` | XML (UTF-8) | when `--metadata-file` is provided | | `` | JSON | when `--attribute-mapping-file` is provided | ## Files Written -| Path | Format | When | -| ---------------------------------------------- | ------ | ------------------------------------------------ | -| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | -| `/supabase/.temp/linked-project.json` | JSON | best-effort after `--project-ref` resolves | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `~/.supabase/telemetry.json` | JSON | always (`Effect.ensuring(telemetryState.flush)`) | +| `/supabase/.temp/linked-project.json` | JSON | in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------------------------------ | ------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------ | -| `GET` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | -| `PUT` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | `{metadata_xml?, metadata_url?, domains, attribute_mapping?, name_id_format?}` | `{id, saml?, domains?, ...}` (parsed loosely) | -| `GET` | `` | none | `Accept: application/xml`, 10s timeout | XML body (UTF-8) — validation when `--skip-url-validation` not set | -| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on 4xx | -| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate | - -Bypasses the typed Management API client for the PUT so user-supplied keys inside -`attribute_mapping.keys.` (e.g. `default`) are preserved verbatim. The initial -GET still uses the typed client. +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------------------------ | ------------ | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | none | `{id, saml?, domains?, created_at?, updated_at?}` | +| `PUT` | `/v1/projects/{ref}/config/auth/sso/providers/{provider_id}` | Bearer token | `{metadata_xml?, metadata_url?, domains, attribute_mapping?, name_id_format?}` | `{id, saml?, domains?, ...}` (typed schema) | +| `GET` | `` | none | `Accept: application/xml`, 10s timeout | XML body (UTF-8) — validation when `--skip-url-validation` not set | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | + +The GET goes through the typed Management API client. The PUT goes through `api.executeRaw` with the +typed `v1UpdateASsoProvider` operation, which keeps the response body undecoded so structured output +emits it as the API returned it; the typed provider schema decodes a copy for the table view (the PUT +retries on 5xx and transport failures like any idempotent request). `--attribute-mapping-file` is validated against +the API's `attribute_mapping` schema after the GET and before the PUT: a key the schema does not +model fails with `failed to parse attribute mapping: Expected no excess property at [...]`. A 2xx +body that does not match the provider schema fails as `SsoUpdateNetworkError` (API-response +classification), for the GET before any PUT. ## Environment Variables @@ -53,10 +61,10 @@ GET still uses the typed client. | `1` | `SsoInvalidUuidError` — provider ID is not a canonical UUID | | `1` | `SsoMutexFlagError` — flag combinations: `--domains` with `--add/--remove-domains`, or `--metadata-file` with `--metadata-url` | | `1` | `SsoUpdateMetadataFileError` — metadata file unreadable, non-UTF-8, or metadata URL invalid/unreachable/non-UTF-8 | -| `1` | `SsoUpdateAttributeMappingFileError` — JSON file unreadable or malformed | +| `1` | `SsoUpdateAttributeMappingFileError` — JSON file unreadable, malformed, or not matching the schema | | `1` | `SsoUpdateNotFoundError` — 404 from GET | | `1` | `SsoUpdateUnexpectedStatusError` — non-2xx from GET or PUT | -| `1` | `SsoUpdateNetworkError` — transport-level failure | +| `1` | `SsoUpdateNetworkError` — transport failure or undecodable 2xx body | ## Telemetry Events Fired @@ -90,7 +98,7 @@ Single `success` event with the parsed response as data. - Flag values are resolved by a raw-argv scan rather than the TS parser: every value the handler acts on (`--project-ref`, `--metadata-file`, `--metadata-url`, `--attribute-mapping-file`, the three domain slices, `--name-id-format`, `--skip-url-validation`) is reconciled against that scan — same mechanism as `sso add`. Repeated flags resolve last-wins (the TS parser is first-wins), and an occurrence with an invalid value — a boolean outside the accepted spellings (`--skip-url-validation=yes`), or a `--name-id-format` outside the enum — fails with the `invalid argument …` message before any validation or request. - Positional arity uses the same scan: the exact-one-argument rule is re-counted over the effective positionals (arity validation runs before every hook and flag validation), so a consumed flag token that orphans its parser-value (`--domains --metadata-url u `) fails with the exact `accepts 1 arg(s), received 2` before the GET — and wins over both the mutex and invalid-UUID errors. - The workdir uses the same scan: the workdir-change step chdir's to the effective `--workdir` (last occurrence, even a flag-shaped consumed token like `--workdir --metadata-file`) or `SUPABASE_WORKDIR`, and a missing directory aborts with the exact `failed to change workdir: chdir …` after the arity check but before the mutex checks and any request. A changed-but-empty `--workdir=` shadows the env var and falls back to the always-valid project-root walk-up. -- The profile uses the same scan: whenever the effective `--profile`/`SUPABASE_PROFILE` token differs from the one the Effect parser gave the config layer (repeats — the scan is last-wins where the parser is first-wins, so `--profile a.yml --profile b.yml` GETs and PUTs `b.yml`'s `api_url`; a flag-shaped consumed value — `--profile --add-domains`; an explicit `--profile supabase` shadowing the env; an untrimmed/empty persisted `~/.supabase/profile` file), the handler re-runs the profile loader on the effective token (`profile-load.ts`). Both the initial GET and the PUT then target that profile's `api_url` — the GET is issued through the raw HTTP client because the typed client bakes the layer's `api_url` in at construction — and a token that can't be loaded aborts with the matching error (`failed to read profile: …` / `failed to parse profile: …` / `invalid profile: …`, byte-exact for the deterministic classes) after the arity check, before the workdir and mutex checks and any request. The raw GET behaves like the typed client on the 200 path too: an undecodable JSON body aborts with `failed to get sso provider: ` before any PUT (detail text is JS `JSON.parse`'s), a 200 without a JSON content type falls into the gate + unexpected-status branch, and the response is stitched through the shared per-command identity guard like the typed client. The upgrade-gate fallback GETs and the linked-project cache fill also target the reconciled host. Where the scan and the parser agree — every normal invocation — the config layer's resolution (including its pre-existing lenient missing/malformed-file fallback, which predates CLI-1982 and applies shell-wide) is used unchanged, via the typed client. +- The profile uses the same scan: whenever the effective `--profile`/`SUPABASE_PROFILE` token differs from the one the Effect parser gave the config layer (repeats — the scan is last-wins where the parser is first-wins, so `--profile a.yml --profile b.yml` GETs and PUTs `b.yml`'s `api_url`; a flag-shaped consumed value — `--profile --add-domains`; an explicit `--profile supabase` shadowing the env; an untrimmed/empty persisted `~/.supabase/profile` file), the handler re-runs the profile loader on the effective token (`profile-load.ts`). Both the initial GET and the PUT then target that profile's `api_url` (a per-request base URL and token on the typed client), and a token that can't be loaded aborts with the matching error (`failed to read profile: …` / `failed to parse profile: …` / `invalid profile: …`, byte-exact for the deterministic classes) after the arity check, before the workdir and mutex checks and any request. The upgrade-gate fallback GETs and the linked-project cache fill also target the reconciled host. Where the scan and the parser agree — every normal invocation — the config layer's resolution (including its pre-existing lenient missing/malformed-file fallback, which predates CLI-1982 and applies shell-wide) is used unchanged. - Profile-load limits (each fail-closed: exit 1 with zero requests; only stderr detail can differ): YAML parse-failure detail text comes from the JS `yaml` parser (shared `failed to read profile: invalid config file: ` prefix); non-YAML/JSON config types (`.toml`, `.env`, …) are parsed as YAML; `http_url`/`hostname_rfc1123`/`uuid4` format checks are approximated. Also: when the effective and layer profiles differ AND the token is keyring-relevant, the keyring token lookup still uses the layer profile's name (env-token flows, e.g. the cli-e2e harness, are unaffected), and the upgrade-suggestion billing URL keeps the layer profile's dashboard host. - Always performs the GET pre-check, regardless of whether `--add-domains` / `--remove-domains` are used. - Domain merge: removals are applied first, then additions. The result is **unordered**; consumers must sort if comparing. diff --git a/apps/cli/src/commands/sso/update/update.command.ts b/apps/cli/src/commands/sso/update/update.command.ts index f8c7be1b2d..0ae68214da 100644 --- a/apps/cli/src/commands/sso/update/update.command.ts +++ b/apps/cli/src/commands/sso/update/update.command.ts @@ -1,6 +1,11 @@ import { Argument, Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { managementApiRuntimeLayer } from "../../../command-internal/management-api-runtime.layer.ts"; import { stringSliceFlag } from "../../../command-internal/string-slice-flag.ts"; @@ -83,6 +88,29 @@ export const ssoUpdateCommand = Command.make("update", config).pipe( description: "Update an SSO provider's domains", }, ]), + withPermissions( + compose( + { + operations: [ + { operationId: "v1-get-a-sso-provider", kind: "required" }, + { operationId: "v1-update-a-sso-provider", kind: "required" }, + ], + noApiEffectFlags: [ + "domains", + "add-domains", + "remove-domains", + "metadata-file", + "metadata-url", + "skip-url-validation", + "attribute-mapping-file", + "name-id-format", + ], + }, + upgradeSuggestion, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => ssoUpdate(flags).pipe( withCommandTelemetry({ flags, safeFlags: ["project-ref"], config }), diff --git a/apps/cli/src/commands/sso/update/update.handler.ts b/apps/cli/src/commands/sso/update/update.handler.ts index 3fae19b7ac..8e4b915640 100644 --- a/apps/cli/src/commands/sso/update/update.handler.ts +++ b/apps/cli/src/commands/sso/update/update.handler.ts @@ -1,11 +1,9 @@ -import type { SupabaseApiError } from "@supabase/api/effect"; -import { Effect, Option, Predicate, Redacted, Stdio } from "effect"; -import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; +import { operationDefinitions, type SupabaseApiError } from "@supabase/api/effect"; +import { Effect, Option, Predicate, type Redacted, Schema, Stdio, type Types } from "effect"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { CommandPlatformApi } from "../../../auth/command-platform-api.service.ts"; import { CommandSettings } from "../../../config/command-settings.service.ts"; -import { IdentityStitch } from "../../../command-internal/identity-stitch.ts"; import { ProjectRefResolver } from "../../../config/project-ref.service.ts"; import { OutputFlag } from "../../../command-internal/global-flags.ts"; import { @@ -15,18 +13,13 @@ import { scanArgvFlags, } from "../../../shared/cli/flag-groups.ts"; import { Output } from "../../../shared/output/output.service.ts"; -import { - encodeSortedJson, - encodeSortedJsonBody, -} from "../../../command-internal/output.encoders.ts"; +import { encodeSortedJson } from "../../../command-internal/output.encoders.ts"; import { encodeStructToml, encodeStructYaml, } from "../../../command-internal/struct-output.encoders.ts"; import { SSO_PROVIDER_RESPONSE_SHAPE } from "../sso.response-shape.ts"; -import { mapHttpError, sanitizeErrorBody } from "../../../command-internal/http-errors.ts"; -import { requestWithAuth } from "../../../command-internal/raw-http.ts"; -import { resolveAccessToken } from "../../../command-internal/resolve-token.ts"; +import { mapHttpError } from "../../../command-internal/http-errors.ts"; import { accessTokenForProfile } from "../../../auth/command-credentials.layer.ts"; import { missingAccessTokenMessage } from "../../../auth/access-token.ts"; import { LinkedProjectCache } from "../../../telemetry/linked-project-cache.service.ts"; @@ -53,14 +46,14 @@ import { SsoAccessTokenError, SsoTomlEncodeError, } from "../sso.errors.ts"; -import { - quoteSsoString, - renderSingleProvider, - toSsoProviderView, - validateUuid, -} from "../sso.format.ts"; +import { quoteSsoString, renderSingleProvider, validateUuid } from "../sso.format.ts"; import { validateMetadataUrl } from "../sso.metadata-url.ts"; -import { SSO_NAME_ID_FORMATS, readAttributeMappingFile, readMetadataFile } from "../sso.saml.ts"; +import { + SSO_NAME_ID_FORMATS, + readAttributeMappingFile, + readMetadataFile, + toNameIdFormat, +} from "../sso.saml.ts"; import type { SsoUpdateFlags } from "./update.command.ts"; const readMetadata = readMetadataFile({ @@ -73,6 +66,17 @@ const readAttributeMapping = readAttributeMappingFile({ openError: (args) => new SsoUpdateAttributeMappingFileError(args), }); +type SsoUpdateBody = Types.Mutable< + Omit< + Schema.Schema.Type, + "ref" | "provider_id" + > +>; + +const decodeProvider = Schema.decodeUnknownEffect( + operationDefinitions.v1UpdateASsoProvider.outputSchema, +); + const mapGetStatusOrNetwork = mapHttpError({ networkError: SsoUpdateNetworkError, statusError: SsoUpdateUnexpectedStatusError, @@ -80,6 +84,14 @@ const mapGetStatusOrNetwork = mapHttpError({ statusMessage: (_status, body) => `unexpected error fetching identity provider: ${body}`, }); +const mapPutStatusOrNetwork = mapHttpError({ + networkError: SsoUpdateNetworkError, + statusError: SsoUpdateUnexpectedStatusError, + networkMessage: (cause) => `failed to update sso provider: ${cause}`, + // Go reuses the GET error message for the PUT. + statusMessage: (_status, body) => `unexpected error fetching identity provider: ${body}`, +}); + const SSO_UPDATE_COMMAND_PATH = ["sso", "update"] as const; /** @@ -114,16 +126,32 @@ const SSO_UPDATE_SCAN_SPEC = { valueFlagShorthands: PERSISTENT_VALUE_FLAG_SHORTHANDS, } as const; -const handleGetError = (ref: string, providerId: string, cause: SupabaseApiError) => +type TokenForAux = Effect.Effect> | undefined, never, R>; + +interface GateContext { + readonly ref: string; + readonly apiUrl: string; + readonly tokenForAux: TokenForAux; +} + +const suggestUpgradeFor = (gate: GateContext, cause: SupabaseApiError, statusCode: number) => + Effect.gen(function* () { + const token = yield* gate.tokenForAux; + return yield* suggestUpgrade({ + projectRef: gate.ref, + featureKey: "auth.saml_2", + statusCode, + response: gateResponse(cause), + apiUrl: gate.apiUrl, + ...(token !== undefined ? { accessToken: token } : {}), + }); + }); + +const handleGetError = (gate: GateContext, providerId: string, cause: SupabaseApiError) => mapGetStatusOrNetwork(cause).pipe( Effect.catchTag("SsoUpdateUnexpectedStatusError", (mapped) => Effect.gen(function* () { - const upgradeSuggested = yield* suggestUpgrade({ - projectRef: ref, - featureKey: "auth.saml_2", - statusCode: mapped.status, - response: gateResponse(cause), - }); + const upgradeSuggested = yield* suggestUpgradeFor(gate, cause, mapped.status); if (mapped.status === 404) { return yield* new SsoUpdateNotFoundError({ message: `An identity provider with ID ${quoteSsoString(providerId)} could not be found.`, @@ -140,31 +168,25 @@ const handleGetError = (ref: string, providerId: string, cause: SupabaseApiError ), ); +const handlePutError = (gate: GateContext, cause: SupabaseApiError) => + mapPutStatusOrNetwork(cause).pipe( + Effect.catchTag("SsoUpdateUnexpectedStatusError", (mapped) => + Effect.gen(function* () { + const upgradeSuggested = yield* suggestUpgradeFor(gate, cause, mapped.status); + return yield* new SsoUpdateUnexpectedStatusError({ + status: mapped.status, + body: mapped.body, + message: mapped.message, + upgradeSuggested, + }); + }), + ), + ); + interface ExistingDomainItem { readonly domain?: string; } -/** - * Narrows a raw GET-provider JSON body to the `domains` shape `mergeDomains` - * consumes. - */ -function extractDomainItems(parsed: unknown): ReadonlyArray | undefined { - if (!Predicate.hasProperty(parsed, "domains")) { - return undefined; - } - const domains = parsed.domains; - if (!Array.isArray(domains)) { - return undefined; - } - return domains.map((item): ExistingDomainItem => { - if (!Predicate.hasProperty(item, "domain")) { - return {}; - } - const domain = item.domain; - return typeof domain === "string" ? { domain } : {}; - }); -} - function mergeDomains( existing: ReadonlyArray | undefined, add: ReadonlyArray, @@ -190,12 +212,10 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag const output = yield* Output; const outputFlag = yield* OutputFlag; const api = yield* CommandPlatformApi; - const httpClient = yield* HttpClient.HttpClient; const cliSettings = yield* CommandSettings; const resolver = yield* ProjectRefResolver; const linkedProjectCache = yield* LinkedProjectCache; const telemetryState = yield* TelemetryState; - const identityStitch = yield* Effect.serviceOption(IdentityStitch); const stdio = yield* Stdio.Stdio; const rawArgs = yield* stdio.args; @@ -300,107 +320,36 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag }); yield* Effect.gen(function* () { + const reconciledToken = + reconciledTokenCached === undefined + ? undefined + : (yield* Effect.filterOrFail( + reconciledTokenCached, + Option.isSome, + () => new SsoAccessTokenError({ message: missingAccessTokenMessage() }), + )).value; + const requestOptions = + reconciledToken === undefined + ? undefined + : { baseUrl: apiUrl, accessToken: reconciledToken }; + const gate = { ref, apiUrl, tokenForAux: reconciledTokenForAux }; + const fetching = output.format === "text" ? yield* output.task("Updating SSO provider...") : undefined; - // The typed client bakes the layer's apiUrl in at construction, so when - // the reconciled profile differs, the GET must be issued raw against - // the effective host, matching the PUT's host. Error mapping and the - // spinner/suggestion ordering mirror the typed path (`handleGetError`). - const rawGetProvider = Effect.gen(function* () { - const tokenOpt = - reconciledTokenCached !== undefined - ? yield* Effect.filterOrFail( - reconciledTokenCached, - Option.isSome, - () => new SsoAccessTokenError({ message: missingAccessTokenMessage() }), - ) - : yield* resolveAccessToken; - const request = requestWithAuth( - HttpClientRequest.get( - `${apiUrl}/v1/projects/${ref}/config/auth/sso/providers/${providerId}`, - ), - tokenOpt, - cliSettings.userAgent, - ); - const response = yield* httpClient.execute(request).pipe( - Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.mapError( - (cause) => - new SsoUpdateNetworkError({ - message: `failed to get sso provider: ${String(cause)}`, - }), - ), - ); - // Every Management API response goes through the identity stitch - // once per command; this raw GET must too, before the status gate. - // `serviceOption`: absent outside the real CLI tree (handler-level - // tests), where no telemetry runtime exists to stitch into. - if (Option.isSome(identityStitch)) { - yield* identityStitch.value.stitch(response); - } - const rawBody = yield* response.text.pipe( + // Always GETs first, regardless of which flags are set. + const existing = yield* api.v1 + .getASsoProvider({ ref, provider_id: providerId }, requestOptions) + .pipe( Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.mapError( - (cause) => - new SsoUpdateNetworkError({ - message: `failed to get sso provider: ${String(cause)}`, - }), - ), + Effect.catch((cause) => handleGetError(gate, providerId, cause)), ); - const contentType = response.headers["content-type"] ?? ""; - if (response.status === 200 && contentType.includes("json")) { - const parsed = yield* Effect.try({ - // oxlint-disable-next-line effecttsgo/prefer-schema-over-json -- Native parser errors are CLI output; schema decoding discards their messages. - try: (): unknown => JSON.parse(rawBody), - catch: (cause) => - new SsoUpdateNetworkError({ - message: `failed to get sso provider: ${cause instanceof Error ? cause.message : String(cause)}`, - decode: true, - }), - }).pipe(Effect.tapError(() => fetching?.fail() ?? Effect.void)); - return { domains: extractDomainItems(parsed) }; - } - // A 200 without a JSON content type falls into this branch too. - yield* fetching?.fail() ?? Effect.void; - const bodyText = sanitizeErrorBody(rawBody); - const upgradeSuggested = yield* suggestUpgrade({ - projectRef: ref, - featureKey: "auth.saml_2", - statusCode: response.status, - response, - apiUrl, - ...(yield* Effect.map(reconciledTokenForAux, (token) => - token !== undefined ? { accessToken: token } : {}, - )), - }); - if (response.status === 404) { - return yield* new SsoUpdateNotFoundError({ - message: `An identity provider with ID ${quoteSsoString(providerId)} could not be found.`, - upgradeSuggested, - }); - } - return yield* new SsoUpdateUnexpectedStatusError({ - status: response.status, - body: bodyText, - message: `unexpected error fetching identity provider: ${bodyText}`, - upgradeSuggested, - }); - }); - // Always GETs first, regardless of which flags are set. - const existing = yield* Option.isSome(profileApiUrl) - ? rawGetProvider - : api.v1.getASsoProvider({ ref, provider_id: providerId }).pipe( - Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.catch((cause) => handleGetError(ref, providerId, cause)), - ); - - const body: Record = {}; + const body: SsoUpdateBody = {}; if (Option.isSome(metadataFile)) { const xml = yield* readMetadata(metadataFile.value); - body["metadata_xml"] = xml; + body.metadata_xml = xml; } else if (Option.isSome(metadataUrl)) { if (!skipUrlValidation) { yield* validateMetadataUrl(metadataUrl.value).pipe( @@ -414,98 +363,59 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag ), ); } - body["metadata_url"] = metadataUrl.value; + body.metadata_url = metadataUrl.value; } if (Option.isSome(attributeMappingFile)) { const mapping = yield* readAttributeMapping(attributeMappingFile.value); - body["attribute_mapping"] = mapping; + body.attribute_mapping = mapping; } if (domains.length > 0) { - body["domains"] = [...domains]; + body.domains = [...domains]; } else { // `domains` is always recomputed and sent, even when no domain flag // was passed; an empty merged set serializes as `"domains":[]`, never omitted. - body["domains"] = mergeDomains(existing.domains, addDomains, removeDomains); + body.domains = mergeDomains(existing.domains, addDomains, removeDomains); } if (Option.isSome(nameIdFormat)) { - body["name_id_format"] = nameIdFormat.value; + const format = toNameIdFormat(nameIdFormat.value); + if (format !== undefined) body.name_id_format = format; } - const tokenOpt = - reconciledTokenCached !== undefined - ? yield* Effect.filterOrFail( - reconciledTokenCached, - Option.isSome, - () => new SsoAccessTokenError({ message: missingAccessTokenMessage() }), - ) - : yield* resolveAccessToken; - - // See `add.handler.ts` for the rationale behind `bearerToken(Redacted)`. - const request = requestWithAuth( - HttpClientRequest.put( - `${apiUrl}/v1/projects/${ref}/config/auth/sso/providers/${providerId}`, - ), - tokenOpt, - cliSettings.userAgent, - ).pipe( - // See `add.handler.ts` — key order matters for cli-e2e parity. - HttpClientRequest.bodyText(encodeSortedJsonBody(body), "application/json"), - ); - - const response = yield* httpClient.execute(request).pipe( + // `executeRaw` keeps the response body intact so structured output emits it as the API + // returned it; the typed schema decodes a copy for the table view. + const { rawBody, provider } = yield* Effect.gen(function* () { + const response = yield* api + .executeRaw( + operationDefinitions.v1UpdateASsoProvider, + { ref, provider_id: providerId, ...body }, + { ...requestOptions, retry: { requestTimeoutMs: Number.POSITIVE_INFINITY } }, + ) + .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); + const rawBody: unknown = yield* response.json; + return { rawBody, provider: yield* decodeProvider(rawBody) }; + }).pipe( + Effect.catch((cause) => handlePutError(gate, cause)), Effect.tapError(() => fetching?.fail() ?? Effect.void), - Effect.mapError( - (cause) => - new SsoUpdateNetworkError({ - message: `failed to update sso provider: ${String(cause)}`, - }), - ), ); - - if (response.status !== 200) { - const rawBody = yield* response.text.pipe(Effect.orElseSucceed(() => "")); - // Cap + sanitize to match `mapHttpError`'s defenses; see add handler for the rationale. - const bodyText = sanitizeErrorBody(rawBody); - const upgradeSuggested = yield* suggestUpgrade({ - projectRef: ref, - featureKey: "auth.saml_2", - statusCode: response.status, - response, - apiUrl, - ...(yield* Effect.map(reconciledTokenForAux, (token) => - token !== undefined ? { accessToken: token } : {}, - )), - }); - yield* fetching?.fail() ?? Effect.void; - // Reuses the GET error message even for PUT. - return yield* new SsoUpdateUnexpectedStatusError({ - status: response.status, - body: bodyText, - message: `unexpected error fetching identity provider: ${bodyText}`, - upgradeSuggested, - }); - } - - const parsedJson = yield* response.json.pipe(Effect.orElseSucceed((): unknown => ({}))); yield* fetching?.clear ?? Effect.void; const outputFlagFormat = Option.getOrUndefined(outputFlag); if (outputFlagFormat === "json") { - yield* output.raw(encodeSortedJson(parsedJson)); + yield* output.raw(encodeSortedJson(rawBody)); return; } if (outputFlagFormat === "yaml") { - yield* output.raw(encodeStructYaml(parsedJson, SSO_PROVIDER_RESPONSE_SHAPE)); + yield* output.raw(encodeStructYaml(rawBody, SSO_PROVIDER_RESPONSE_SHAPE)); return; } if (outputFlagFormat === "toml") { // Same TOML-encode-failure pattern as list/show. const toml = yield* Effect.try({ - try: () => encodeStructToml(parsedJson, SSO_PROVIDER_RESPONSE_SHAPE), + try: () => encodeStructToml(rawBody, SSO_PROVIDER_RESPONSE_SHAPE), catch: (cause) => new SsoTomlEncodeError({ message: `failed to output toml: ${cause instanceof Error ? cause.message : String(cause)}`, @@ -521,12 +431,12 @@ export const ssoUpdate = Effect.fn("sso.update")(function* (flags: SsoUpdateFlag if (output.format === "json" || output.format === "stream-json") { yield* output.success( "", - Predicate.isObjectOrArray(parsedJson) ? { ...parsedJson } : { value: parsedJson }, + Predicate.isObjectOrArray(rawBody) ? { ...rawBody } : { value: rawBody }, ); return; } - yield* output.raw(renderSingleProvider(toSsoProviderView(parsedJson))); + yield* output.raw(renderSingleProvider(provider)); }).pipe( // Linked-project cache fill GETs `/v1/projects/{ref}` through the // reconciled host, never the config layer's. diff --git a/apps/cli/src/commands/sso/update/update.integration.test.ts b/apps/cli/src/commands/sso/update/update.integration.test.ts index 18f32cd05b..19ebb1052d 100644 --- a/apps/cli/src/commands/sso/update/update.integration.test.ts +++ b/apps/cli/src/commands/sso/update/update.integration.test.ts @@ -17,9 +17,15 @@ import { import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; -import { mockAnalytics, mockOutput, mockRuntimeInfo } from "../../../../tests/helpers/mocks.ts"; +import { + mockAnalytics, + mockOutput, + mockRuntimeInfo, + mockTty, +} from "../../../../tests/helpers/mocks.ts"; import { buildTestRuntime, + DEFAULT_API_URL, VALID_REF, mockCommandSettings, mockLinkedProjectCacheTracked, @@ -27,8 +33,12 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../../tests/helpers/permission-real-layers.ts"; import { ProfileFlag } from "../../../command-internal/global-flags.ts"; -import { IdentityStitch } from "../../../command-internal/identity-stitch.ts"; import { EventUpgradeSuggested } from "../../../shared/telemetry/event-catalog.ts"; import { classifyCliCauseActionability } from "../../../shared/telemetry/error-actionability.ts"; import type { SsoUpdateFlags } from "./update.command.ts"; @@ -48,6 +58,8 @@ const RESPONSE_PROVIDER = { domains: [{ domain: "new.com" }], }; +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + const tempRoot = useTempWorkdir("supabase-sso-update-int-"); interface SetupOpts { @@ -55,12 +67,6 @@ interface SetupOpts { outputFlag?: "env" | "pretty" | "json" | "toml" | "yaml"; getStatus?: number; getBody?: unknown; - /** - * Serves the provider GET as a raw body + content type instead of - * `jsonResponse`, for the reconciled-profile raw GET's decode branches - * (invalid JSON, non-JSON content type). - */ - getRaw?: { status: number; body: string; contentType: string }; putStatus?: number; putBody?: unknown; upgradeGate?: "gated" | "notGated"; @@ -114,17 +120,6 @@ function setup(opts: SetupOpts = {}) { const url = request.url; if (url.includes("/config/auth/sso/providers/")) { if (request.method === "GET") { - if (opts.getRaw !== undefined) { - return Effect.succeed( - HttpClientResponse.fromWeb( - request, - new Response(opts.getRaw.body, { - status: opts.getRaw.status, - headers: { "content-type": opts.getRaw.contentType }, - }), - ), - ); - } return Effect.succeed(jsonResponse(request, getStatus, getBody)); } if (request.method === "PUT") @@ -169,17 +164,6 @@ function setup(opts: SetupOpts = {}) { }, }); - // Tracks that the reconciled-profile raw GET stitches identity through the - // same shared guard as the typed client's response transform. - let stitchedResponses = 0; - const stitchLayer = Layer.succeed(IdentityStitch, { - stitch: () => - Effect.sync(() => { - stitchedResponses += 1; - }), - stitchedDistinctId: () => undefined, - }); - const cliSettings = mockCommandSettings({ workdir: tempRoot.current, ...(opts.accessToken !== undefined ? { accessToken: opts.accessToken } : {}), @@ -198,7 +182,6 @@ function setup(opts: SetupOpts = {}) { Stdio.layerTest({ args: Effect.succeed(opts.cliArgs ?? ["sso", "update", VALID_PROVIDER_ID]), }), - stitchLayer, opts.profileFlag === undefined ? Layer.empty : Layer.succeed(ProfileFlag, opts.profileFlag), ConfigProvider.layer( ConfigProvider.fromEnvRecord({ SUPABASE_NO_KEYRING: "1" }, { preserveEmptyStrings: true }), @@ -212,9 +195,6 @@ function setup(opts: SetupOpts = {}) { analytics, telemetry, cache, - get stitchedResponses() { - return stitchedResponses; - }, }; } @@ -290,17 +270,77 @@ describe("sso update integration", () => { yield* ssoUpdate(defaultFlags); const methods = api.requests.map((r) => r.method); expect(methods.indexOf("GET")).toBeLessThan(methods.indexOf("PUT")); + assertPermissionDrift({ command: "sso update", requests: api.requests, exact: true }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the projects for the picker and fills the linked-project cache", () => { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const projectsUrl = `${DEFAULT_API_URL}/v1/projects`; + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.method === "GET" && request.url === projectsUrl) { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.method === "GET" && request.url === `${projectsUrl}/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + if (request.method === "GET") { + return Effect.succeed(jsonResponse(request, 200, EXISTING_PROVIDER)); + } + return Effect.succeed(jsonResponse(request, 200, RESPONSE_PROVIDER)); + }, + }); + const cliSettings = mockCommandSettings({ + workdir: tempRoot.current, + projectId: Option.none(), + }); + const layer = Layer.mergeAll( + buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ + httpClient: api.httpClientLayer, + cliSettings, + }), + }), + Stdio.layerTest({ args: Effect.succeed(["sso", "update", VALID_PROVIDER_ID]) }), + ); + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const cachePath = `${tempRoot.current}/supabase/.temp/linked-project.json`; + expect(yield* fs.exists(cachePath)).toBe(false); + yield* ssoUpdate(defaultFlags); + const providerUrl = `${projectsUrl}/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`; + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${projectsUrl}`, + `GET ${providerUrl}`, + `PUT ${providerUrl}`, + `GET ${projectsUrl}/${VALID_REF}`, + ]); + expect(out.stdoutText).toContain(VALID_PROVIDER_ID); + expect(yield* decodeJson(yield* fs.readFileString(cachePath))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ command: "sso update", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); it.effect("GET 404 → NotFound error", () => { - const { layer } = setup({ getStatus: 404, getBody: {} }); + const { layer, api } = setup({ getStatus: 404, getBody: {} }); return Effect.gen(function* () { const exit = yield* Effect.exit(ssoUpdate(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { expect(Cause.pretty(exit.cause)).toContain("SsoUpdateNotFoundError"); } + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET ${DEFAULT_API_URL}/v1/projects/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`, + `GET ${DEFAULT_API_URL}/v1/projects/${VALID_REF}`, + ]); + assertPermissionDrift({ command: "sso update", requests: api.requests }); }).pipe(Effect.provide(layer)); }); @@ -1254,6 +1294,24 @@ describe("sso update integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("a GET body that fails the provider schema aborts before the PUT", () => { + const { layer, api } = setup({ + getBody: { id: VALID_PROVIDER_ID, domains: ["not-an-object"] }, + }); + return Effect.gen(function* () { + const exit = yield* Effect.exit(ssoUpdate(defaultFlags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = Cause.pretty(exit.cause); + expect(dump).toContain("SsoUpdateNetworkError"); + expect(dump).toContain("failed to get sso provider:"); + const classified = classifyCliCauseActionability(exit.cause); + expect(classified.error_fingerprint).toBe("tag:SsoUpdateNetworkError:api_response"); + } + expect(api.requests.some((r) => r.method === "PUT")).toBe(false); + }).pipe(Effect.provide(layer)); + }); + it.effect("PUT 4xx + gated entitlement → unexpected error + cli_upgrade_suggested", () => { // suggestUpgrade fires only on 4xx. const { layer, analytics } = setup({ @@ -1265,7 +1323,11 @@ describe("sso update integration", () => { const exit = yield* Effect.exit(ssoUpdate(defaultFlags)); expect(Exit.isFailure(exit)).toBe(true); if (Exit.isFailure(exit)) { - expect(Cause.pretty(exit.cause)).toContain("SsoUpdateUnexpectedStatusError"); + const dump = Cause.pretty(exit.cause); + expect(dump).toContain("SsoUpdateUnexpectedStatusError"); + expect(dump).toContain( + 'unexpected error fetching identity provider: {"error":"forbidden"}', + ); } expect(analytics.captured.some((c) => c.event === EventUpgradeSuggested)).toBe(true); }).pipe(Effect.provide(layer)); @@ -1287,6 +1349,17 @@ describe("sso update integration", () => { }).pipe(Effect.provide(layer)); }); + it.effect("Go --output=json keeps response keys outside the OpenAPI schema", () => { + const { layer, out } = setup({ + outputFlag: "json", + putBody: { ...RESPONSE_PROVIDER, resource_id: "res-123" }, + }); + return Effect.gen(function* () { + yield* ssoUpdate(defaultFlags); + expect(out.stdoutText).toMatch(/"resource_id":\s*"res-123"/); + }).pipe(Effect.provide(layer)); + }); + it.effect("TS --output-format=json emits success", () => { const { layer, out } = setup({ format: "json" }); return Effect.gen(function* () { @@ -1376,6 +1449,27 @@ describe("sso update integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); + it.effect("an attribute mapping with an unknown key fails before the PUT", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const pathService = yield* Path.Path; + const path = pathService.join(tempRoot.current, "unknown-key.json"); + yield* fs.writeFileString(path, '{"keys":{"email":{"nam":"email"}}}'); + const flags = { ...defaultFlags, attributeMappingFile: Option.some(path) }; + const { layer, api } = setup({ cliArgs: cliArgsFor(flags) }); + return yield* Effect.gen(function* () { + const exit = yield* Effect.exit(ssoUpdate(flags)); + expect(Exit.isFailure(exit)).toBe(true); + if (Exit.isFailure(exit)) { + const dump = Cause.pretty(exit.cause); + expect(dump).toContain("SsoUpdateAttributeMappingFileError"); + expect(dump).toContain("failed to parse attribute mapping: Expected no excess property"); + } + expect(api.requests.some((r) => r.method === "PUT")).toBe(false); + }).pipe(Effect.provide(layer)); + }).pipe(Effect.provide(BunServices.layer)), + ); + it.effect("--add-domains + --remove-domains combined apply remove then add", () => { const flags = { ...defaultFlags, addDomains: ["new.com"], removeDomains: ["old1.com"] }; const { layer, api } = setup({ cliArgs: cliArgsFor(flags) }); @@ -1413,11 +1507,10 @@ describe("sso update integration", () => { Effect.gen(function* () { const first = yield* writeProfileYaml("first.yml", "http://first.example"); const second = yield* writeProfileYaml("second.yml", "http://second.example"); - const testSetup = setup({ + const { layer, api, cache } = setup({ cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--profile", first, "--profile", second], profileFlag: first, }); - const { layer, api, cache } = testSetup; return yield* Effect.gen(function* () { yield* ssoUpdate(defaultFlags); const providerUrl = `http://second.example/v1/projects/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`; @@ -1428,101 +1521,13 @@ describe("sso update integration", () => { const { domains } = yield* decodePutDomains(put?.body); expect([...domains].sort()).toEqual(["old1.com", "old2.com"]); expect(api.requests.some((r) => r.url.startsWith("http://first.example/"))).toBe(false); - expect(testSetup.stitchedResponses).toBeGreaterThan(0); + expect(get?.headers["authorization"]).toMatch(/^Bearer sbp_/); + expect(put?.headers["authorization"]).toMatch(/^Bearer sbp_/); expect(cache.cachedApiUrl).toBe("http://second.example"); }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); }).pipe(Effect.provide(BunServices.layer)), ); - it.effect( - "profile emulation: the reconciled-host GET maps a 404 exactly like the typed client", - () => - Effect.gen(function* () { - const first = yield* writeProfileYaml("first-404.yml", "http://first.example"); - const second = yield* writeProfileYaml("second-404.yml", "http://second.example"); - const { layer, api } = setup({ - getStatus: 404, - getBody: {}, - cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--profile", first, "--profile", second], - profileFlag: first, - }); - return yield* Effect.gen(function* () { - const exit = yield* Effect.exit(ssoUpdate(defaultFlags)); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const dump = Cause.pretty(exit.cause); - expect(dump).toContain("SsoUpdateNotFoundError"); - expect(dump).toContain( - `An identity provider with ID "${VALID_PROVIDER_ID}" could not be found.`, - ); - } - expect(api.requests.some((r) => r.method === "PUT")).toBe(false); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect( - "profile emulation: the reconciled-host GET maps a non-404 status exactly like the typed client", - () => - Effect.gen(function* () { - const first = yield* writeProfileYaml("first-500.yml", "http://first.example"); - const second = yield* writeProfileYaml("second-500.yml", "http://second.example"); - const { layer, api } = setup({ - getStatus: 500, - getBody: { error: "boom" }, - cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--profile", first, "--profile", second], - profileFlag: first, - }); - return yield* Effect.gen(function* () { - const exit = yield* Effect.exit(ssoUpdate(defaultFlags)); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const dump = Cause.pretty(exit.cause); - expect(dump).toContain("SsoUpdateUnexpectedStatusError"); - expect(dump).toContain("unexpected error fetching identity provider:"); - } - expect(api.requests.some((r) => r.method === "PUT")).toBe(false); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect( - "profile emulation: the reconciled GET narrows odd JSON shapes when merging domains", - () => - Effect.gen(function* () { - const first = yield* writeProfileYaml("first-merge.yml", "http://first.example"); - const second = yield* writeProfileYaml("second-merge.yml", "http://second.example"); - const { layer, api, cache } = setup({ - getBody: { - id: VALID_PROVIDER_ID, - domains: [{ domain: "old1.com" }, "not-an-object", { domain: 42 }], - }, - cliArgs: [ - "sso", - "update", - VALID_PROVIDER_ID, - "--add-domains", - "new.com", - "--profile", - first, - "--profile", - second, - ], - profileFlag: first, - }); - return yield* Effect.gen(function* () { - yield* ssoUpdate({ ...defaultFlags, addDomains: ["new.com"] }); - const put = api.requests.find((r) => r.method === "PUT"); - expect(put?.url).toBe( - `http://second.example/v1/projects/${VALID_REF}/config/auth/sso/providers/${VALID_PROVIDER_ID}`, - ); - const { domains } = yield* decodePutDomains(put?.body); - expect([...domains].sort()).toEqual(["new.com", "old1.com"]); - expect(cache.cachedAccessToken).toBeDefined(); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); - }).pipe(Effect.provide(BunServices.layer)), - ); - it.effect("profile emulation: a reconciled profile with no resolvable token aborts", () => Effect.gen(function* () { const first = yield* writeProfileYaml("first-notoken.yml", "http://first.example"); @@ -1591,33 +1596,6 @@ describe("sso update integration", () => { }).pipe(Effect.provide(BunServices.layer)), ); - it.effect("profile emulation: the reconciled GET tolerates a body without a domains array", () => - Effect.gen(function* () { - const first = yield* writeProfileYaml("first-nodom.yml", "http://first.example"); - const second = yield* writeProfileYaml("second-nodom.yml", "http://second.example"); - const { layer, api } = setup({ - getBody: { id: VALID_PROVIDER_ID }, - cliArgs: [ - "sso", - "update", - VALID_PROVIDER_ID, - "--add-domains", - "new.com", - "--profile", - first, - "--profile", - second, - ], - profileFlag: first, - }); - return yield* Effect.gen(function* () { - yield* ssoUpdate({ ...defaultFlags, addDomains: ["new.com"] }); - const put = api.requests.find((r) => r.method === "PUT"); - expect(put?.body).toHaveProperty("domains", ["new.com"]); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); - }).pipe(Effect.provide(BunServices.layer)), - ); - it.effect( "profile emulation: --profile consuming a trailing flag token fails LoadProfile, never GETs", () => { @@ -1637,58 +1615,6 @@ describe("sso update integration", () => { }, ); - it.effect( - "profile emulation: an undecodable 200 body from the reconciled GET aborts before the PUT", - () => - Effect.gen(function* () { - const first = yield* writeProfileYaml("first-badjson.yml", "http://first.example"); - const second = yield* writeProfileYaml("second-badjson.yml", "http://second.example"); - const { layer, api } = setup({ - getRaw: { status: 200, body: "{not json", contentType: "application/json" }, - cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--profile", first, "--profile", second], - profileFlag: first, - }); - return yield* Effect.gen(function* () { - const exit = yield* Effect.exit(ssoUpdate(defaultFlags)); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const dump = Cause.pretty(exit.cause); - expect(dump).toContain("SsoUpdateNetworkError"); - expect(dump).toContain("failed to get sso provider:"); - const classified = classifyCliCauseActionability(exit.cause); - expect(classified.error_kind).toBe("external_service"); - expect(classified.error_category).toBe("api_status"); - expect(classified.error_fingerprint).toBe("tag:SsoUpdateNetworkError:api_response"); - } - expect(api.requests.some((r) => r.method === "PUT")).toBe(false); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); - }).pipe(Effect.provide(BunServices.layer)), - ); - - it.effect( - "profile emulation: a 200 without a JSON content type maps to the unexpected-status branch", - () => - Effect.gen(function* () { - const first = yield* writeProfileYaml("first-nonjson.yml", "http://first.example"); - const second = yield* writeProfileYaml("second-nonjson.yml", "http://second.example"); - const { layer, api } = setup({ - getRaw: { status: 200, body: "plain text body", contentType: "text/plain" }, - cliArgs: ["sso", "update", VALID_PROVIDER_ID, "--profile", first, "--profile", second], - profileFlag: first, - }); - return yield* Effect.gen(function* () { - const exit = yield* Effect.exit(ssoUpdate(defaultFlags)); - expect(Exit.isFailure(exit)).toBe(true); - if (Exit.isFailure(exit)) { - const dump = Cause.pretty(exit.cause); - expect(dump).toContain("SsoUpdateUnexpectedStatusError"); - expect(dump).toContain("unexpected error fetching identity provider: plain text body"); - } - expect(api.requests.some((r) => r.method === "PUT")).toBe(false); - }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); - }).pipe(Effect.provide(BunServices.layer)), - ); - it.effect( "profile emulation: a gated 4xx on the reconciled GET sends the fallback gate requests to the reconciled host", () => @@ -1718,6 +1644,11 @@ describe("sso update integration", () => { "http://second.example/v1/organizations/acme/entitlements", ); expect(api.requests.some((r) => r.url.startsWith("http://first.example/"))).toBe(false); + assertPermissionDrift({ + command: "sso update", + requests: api.requests, + apiUrl: "http://second.example", + }); }).pipe(Effect.provide(layer), (body) => withProfileEnv(undefined, body)); }).pipe(Effect.provide(BunServices.layer)), ); diff --git a/apps/cli/src/commands/start/start.command.ts b/apps/cli/src/commands/start/start.command.ts index a190df5fcc..6b88bd43e1 100644 --- a/apps/cli/src/commands/start/start.command.ts +++ b/apps/cli/src/commands/start/start.command.ts @@ -2,6 +2,7 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; import { stdinLayer } from "../../shared/runtime/stdin.layer.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; @@ -88,6 +89,10 @@ const startRuntimeLayer = Layer.mergeAll( export const startCommand = Command.make("start", config).pipe( Command.withDescription("Start containers for Supabase local development."), Command.withShortDescription("Start local Supabase stack"), + withPermissions({ + operations: [], + noApiEffectFlags: ["exclude", "ignore-health-check", "preview"], + }), Command.withHandler((flags) => start(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/start/start.integration.test.ts b/apps/cli/src/commands/start/start.integration.test.ts index 6c0dcccf4c..1116bf8157 100644 --- a/apps/cli/src/commands/start/start.integration.test.ts +++ b/apps/cli/src/commands/start/start.integration.test.ts @@ -32,6 +32,7 @@ import { mockTty, } from "../../../tests/helpers/mocks.ts"; import { + mockCommandPlatformApi, mockCommandSettings, mockTelemetryStateTracked, useTempWorkdir, @@ -40,6 +41,7 @@ import { withEmptyConfigEnv, withEnvVar, } from "../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; import { unusedStackServices } from "../../../tests/helpers/unused-stack.ts"; import { CliArgs } from "../../shared/cli/cli-args.service.ts"; import { classifyCliCauseActionability } from "../../shared/telemetry/error-actionability.ts"; @@ -477,6 +479,8 @@ interface SetupOpts { /** Observes files decoded from the in-memory tar stream passed to `docker cp -`. */ readonly onSecretCopy?: (containerPath: string, content: string) => void; readonly httpClientLayer?: Layer.Layer; + /** Replaces the factory that dies when a local start reaches for the Management API. */ + readonly platformApiFactoryLayer?: Layer.Layer; readonly configuredProjectId?: string; /** Raw `config.toml` contents — overrides `configuredProjectId`'s single-line default. */ readonly configContents?: string; @@ -529,9 +533,10 @@ const setup = Effect.fnUntraced(function* (opts: SetupOpts = {}) { dockerRunLayer.pipe(Layer.provide(child.layer), Layer.provide(mockProcessControl().layer)), mockProcessControl().layer, mockRuntimeInfo({ platform: "linux" }), - Layer.succeed(CommandPlatformApiFactory, { - make: Effect.die("CommandPlatformApiFactory should not be used by a local start"), - }), + opts.platformApiFactoryLayer ?? + Layer.succeed(CommandPlatformApiFactory, { + make: Effect.die("CommandPlatformApiFactory should not be used by a local start"), + }), Layer.succeed(CliArgs, { args: ["start"] }), Layer.succeed(DebugFlag, false), Layer.succeed(YesFlag, false), @@ -2617,14 +2622,22 @@ content_path = "./supabase/templates/custom_notice.html" it.effect("seeds a configured bucket on a fresh volume with storage enabled", () => Effect.gen(function* () { const http = mockStorageBucketHttpClient(); + const api = mockCommandPlatformApi(); const { layer } = yield* setup({ configContents: 'project_id = "demo"\n[storage.buckets.avatars]\npublic = false\n', route: freshVolumeRoute(defaultRoute()), httpClientLayer: http.layer, + platformApiFactoryLayer: api.factoryLayer, }); yield* start(flags({ exclude: ["edge-runtime"] })).pipe(Effect.provide(layer)); expect(http.createdBucketRequests).toHaveLength(1); + assertPermissionDrift({ + command: "start", + requests: [...http.requests, ...api.requests], + activeFlags: { exclude: true }, + exact: true, + }); // docker.io Storage carries its own Docker healthcheck, so readiness // never goes through the gateway. expect(http.requests.some((entry) => entry.url.includes("/storage/v1/status"))).toBe(false); diff --git a/apps/cli/src/commands/status/status.command.ts b/apps/cli/src/commands/status/status.command.ts index 47297f575c..0f029dfc28 100644 --- a/apps/cli/src/commands/status/status.command.ts +++ b/apps/cli/src/commands/status/status.command.ts @@ -2,6 +2,9 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose, withContext } from "../../command-internal/command-permissions/model.ts"; +import { branchTarget } from "../../command-internal/branch-target.ts"; import { commandCredentialsLayer } from "../../auth/command-credentials.layer.ts"; import { commandPlatformApiFactoryLayer } from "../../auth/command-platform-api-factory.layer.ts"; import { commandSettingsLayer } from "../../config/command-settings.layer.ts"; @@ -42,9 +45,9 @@ const config = { export type StatusFlags = CliCommand.Command.Config.Infer; -// `status` makes no Management API calls, so it avoids `managementApiRuntimeLayer` — the eager -// `CommandPlatformApi` stack that resolves a token at layer build time and fails outright -// without one. +// `status` avoids `managementApiRuntimeLayer` — the eager `CommandPlatformApi` stack that +// resolves a token at layer build time and fails outright without one — because its only +// Management API call is the lazy best-effort branch lookup below. const cliSettings = commandSettingsLayer.pipe(Layer.provide(debugLoggerLayer)); // Lazy Management API handle for `resolveLinkedState`'s best-effort branch-name lookup: @@ -83,6 +86,15 @@ export const statusCommand = Command.make("status", config).pipe( description: "Output status as JSON", }, ]), + withPermissions( + compose( + { operations: [], noApiEffectFlags: ["override-name", "exclude", "ignore-health-check"] }, + withContext( + branchTarget, + "the linked project ref (SUPABASE_PROJECT_ID or supabase/.temp/project-ref) differs from the parent ref in supabase/.temp/linked-project.json, and an access token resolves", + ), + ), + ), Command.withHandler((flags) => status(flags).pipe( withCommandTelemetry({ diff --git a/apps/cli/src/commands/status/status.integration.test.ts b/apps/cli/src/commands/status/status.integration.test.ts index 9ac9e46d68..d54e67f147 100644 --- a/apps/cli/src/commands/status/status.integration.test.ts +++ b/apps/cli/src/commands/status/status.integration.test.ts @@ -28,11 +28,13 @@ import { statusCodeFailure, transportFailure, mockCommandSettings, + mockCommandPlatformApi, mockCommandPlatformApiService, mockTelemetryStateTracked, useTempWorkdir, withEnvVar, } from "../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; import type { CommandPlatformApiFactoryError } from "../../auth/command-platform-api-factory.service.ts"; import { CommandPlatformApiFactory } from "../../auth/command-platform-api-factory.service.ts"; import { CommandPlatformApi } from "../../auth/command-platform-api.service.ts"; @@ -319,6 +321,8 @@ interface SetupOpts { readonly fail?: unknown; readonly makeFails?: CommandPlatformApiFactoryError; }; + /** A ready-made `CommandPlatformApiFactory` layer, e.g. one that records HTTP requests. */ + readonly apiFactoryLayer?: Layer.Layer; /** `SUPABASE_PROJECT_ID` for the linked-state chain — defaults to unset. */ readonly projectId?: Option.Option; } @@ -360,6 +364,7 @@ function setup(opts: SetupOpts = {}) { Layer.succeed(OutputFlag, opts.outputFlag ?? Option.none()), ...(apiMock === undefined ? [] : [apiMock.layer]), ...(apiFactoryMock === undefined ? [] : [apiFactoryMock.layer]), + ...(opts.apiFactoryLayer === undefined ? [] : [opts.apiFactoryLayer]), ); return { workdir, out, telemetry, child, layer, apiMock, apiFactoryMock }; @@ -1554,6 +1559,35 @@ content_path = "./supabase/templates/password_changed_notification.html" }, ); + it.effect.each([ + { + answer: "200", + response: { status: 200, body: [LINKED_BRANCH] }, + branchLine: ` Branch: feature-x (${LINKED_BRANCH_REF})\n`, + }, + { + answer: "403", + response: { status: 403, body: { message: "forbidden" } }, + branchLine: ` Branch: ${LINKED_BRANCH_REF}\n`, + }, + ])("branch-linked: the branch lookup answers $answer over HTTP", ({ response, branchLine }) => { + const api = mockCommandPlatformApi({ response }); + const { layer, out, workdir } = setup({ apiFactoryLayer: api.factoryLayer }); + return Effect.gen(function* () { + yield* writeConfig(); + yield* writeProjectRefFile(workdir, LINKED_BRANCH_REF); + yield* writeLinkedProjectCacheFile(workdir, LINKED_PARENT_REF, { + name: "Parent Project", + }); + yield* status(flags()); + expect(api.requests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + `GET https://api.supabase.com/v1/projects/${LINKED_PARENT_REF}/branches`, + ]); + expect(out.stdoutText).toContain(branchLine); + assertPermissionDrift({ command: "status", requests: api.requests }); + }).pipe(Effect.provide(layer)); + }); + it.effect( "branch-linked with the branch lookup failing (status error): RICH degraded block, still succeeds", () => { diff --git a/apps/cli/src/commands/stop/SIDE_EFFECTS.md b/apps/cli/src/commands/stop/SIDE_EFFECTS.md index 13f2a47fb5..41037d0bb1 100644 --- a/apps/cli/src/commands/stop/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/stop/SIDE_EFFECTS.md @@ -158,12 +158,13 @@ Same payload as `json`, delivered as a `result` NDJSON event. `--backup`: `deleteVolumes = flags.noBackup`. `--backup=false` alone does **not** delete volumes; only `--no-backup` does. -- Volume prune gates `--all` on the Docker daemon's API version (`container-cli.ts`'s +- Volume prune gates `--all` on the Docker client and daemon API versions (`container-cli.ts`'s `dockerSupportsVolumePruneAllFlag`, checked via `docker version --format -'{{.Server.APIVersion}}'`) — Docker requires server API version >= 1.42. This isn't - cosmetic: Docker CLI's own `--all` flag on `volume prune` is annotated `version: "1.42"` - and enforced before pruning runs, so sending it unconditionally on a pre-1.42 daemon - hard-fails the whole call instead of just pruning a narrower set. On the Podman fallback, +'{{.Client.APIVersion}} {{.Server.APIVersion}}'`) — Docker requires both to be >= 1.42. This + isn't cosmetic: a pre-23.0 Docker CLI doesn't know `--all` on `volume prune`, and a newer + CLI annotates it `version: "1.42"` and rejects it when the daemon is below 1.42, before + pruning runs, so sending it unconditionally hard-fails the whole call instead of just + pruning a narrower set. On the Podman fallback, `--all` is omitted unconditionally instead: no released Podman `volume prune` (checked v4.3 through the current v5.7) accepts that flag, and Podman already prunes every unused volume by default, so dropping it there is lossless. Podman is a fallback runtime reached by diff --git a/apps/cli/src/commands/stop/stop.command.ts b/apps/cli/src/commands/stop/stop.command.ts index b596f4728d..6619baf297 100644 --- a/apps/cli/src/commands/stop/stop.command.ts +++ b/apps/cli/src/commands/stop/stop.command.ts @@ -2,6 +2,7 @@ import { Layer } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { commandSettingsLayer } from "../../config/command-settings.layer.ts"; @@ -52,6 +53,10 @@ const stopRuntimeLayer = Layer.mergeAll( export const stopCommand = Command.make("stop", config).pipe( Command.withDescription("Stop all local Supabase containers."), Command.withShortDescription("Stop all local Supabase containers"), + withPermissions({ + operations: [], + noApiEffectFlags: ["project-id", "backup", "no-backup", "all"], + }), Command.withHandler((flags) => stop(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/stop/stop.integration.test.ts b/apps/cli/src/commands/stop/stop.integration.test.ts index 367a0f0f65..e256c4fea9 100644 --- a/apps/cli/src/commands/stop/stop.integration.test.ts +++ b/apps/cli/src/commands/stop/stop.integration.test.ts @@ -160,24 +160,35 @@ function mockRoutedContainerCliSpawner( } /** - * Default happy-path router: `ps` lists one container, `docker version` reports an API version - * above the `volume prune --all` gate (see `dockerSupportsVolumePruneAllFlag`), everything else - * succeeds empty. + * Default happy-path router: `ps` lists one container, `docker version` renders the requested + * `--format` with client and daemon API versions above the `volume prune --all` gate (see + * `dockerSupportsVolumePruneAllFlag`), everything else succeeds empty. */ function defaultRoute( opts: { readonly containerIds?: ReadonlyArray; readonly volumeNames?: ReadonlyArray; - readonly dockerApiVersion?: string; + readonly dockerClientApiVersion?: string; + readonly dockerServerApiVersion?: string; } = {}, ) { const containerIds = opts.containerIds ?? ["c1"]; const volumeNames = opts.volumeNames ?? []; - const dockerApiVersion = opts.dockerApiVersion ?? "1.45"; + const dockerClientApiVersion = opts.dockerClientApiVersion ?? "1.45"; + const dockerServerApiVersion = opts.dockerServerApiVersion ?? "1.45"; return (args: ReadonlyArray): RouteResult => { if (args[0] === "ps") return { stdout: containerIds.map((id) => `${id}\t${id}`) }; if (args[0] === "volume" && args[1] === "ls") return { stdout: volumeNames }; - if (args[0] === "version") return { stdout: [dockerApiVersion] }; + if (args[0] === "version") { + const format = args[args.indexOf("--format") + 1] ?? ""; + return { + stdout: [ + format + .replaceAll("{{.Client.APIVersion}}", dockerClientApiVersion) + .replaceAll("{{.Server.APIVersion}}", dockerServerApiVersion), + ], + }; + } return { exitCode: 0 }; }; } @@ -717,7 +728,27 @@ describe("stop integration", () => { // nothing) on an older daemon. const { layer, child } = yield* setup({ configuredProjectId: "demo", - route: defaultRoute({ dockerApiVersion: "1.41" }), + route: defaultRoute({ dockerServerApiVersion: "1.41" }), + }); + yield* stop(flags({ noBackup: true })).pipe(Effect.provide(layer)); + const volumePrune = child.spawned.find( + (s) => s.command === "docker" && s.args[0] === "volume" && s.args[1] === "prune", + ); + expect(volumePrune?.args).toEqual([ + "volume", + "prune", + "--force", + "--filter", + "label=com.supabase.cli.project=demo", + ]); + }).pipe(Effect.provide(BunServices.layer)), + ); + + it.effect("omits --all from docker's volume prune when an old client talks to a new daemon", () => + Effect.gen(function* () { + const { layer, child } = yield* setup({ + configuredProjectId: "demo", + route: defaultRoute({ dockerClientApiVersion: "1.41", dockerServerApiVersion: "1.44" }), }); yield* stop(flags({ noBackup: true })).pipe(Effect.provide(layer)); const volumePrune = child.spawned.find( @@ -737,7 +768,7 @@ describe("stop integration", () => { Effect.gen(function* () { const { layer, child } = yield* setup({ configuredProjectId: "demo", - route: defaultRoute({ dockerApiVersion: "1.42" }), + route: defaultRoute({ dockerClientApiVersion: "1.42", dockerServerApiVersion: "1.42" }), }); yield* stop(flags({ noBackup: true })).pipe(Effect.provide(layer)); const volumePrune = child.spawned.find( diff --git a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md index f54daf7a9d..8c9993bf41 100644 --- a/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/cp/SIDE_EFFECTS.md @@ -11,41 +11,45 @@ credential, capability-state, and error behavior. ## Files Read -| Path | Format | When | -| --------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (local creds; `[storage.buckets.*]` for bucket auto-create). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | -| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | -| `/supabase/.env*`, `/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | -| upload source files | bytes | upload: sniff (≤512 bytes) + streamed body | +| Path | Format | When | +| ---------------------------------------------- | ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (local creds; `[storage.buckets.*]` for bucket auto-create). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | +| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | +| `/supabase/.temp/project-ref` | plain text | linked path, when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/linked-project.json` | JSON | linked path, existence check before the cache fill | +| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | +| `/supabase/.env*`, `/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | +| upload source files | bytes | upload: sniff (≤512 bytes) + streamed body | ## Files Written -| Path | Format | When | -| ---------------------------------------- | ------ | ------------------------------------------------------ | -| download destination files | bytes | download (single: O_EXCL `wx`; recursive: O_TRUNC `w`) | -| download destination parent dirs | dir | recursive download (`mkdir -p`) | -| `~/.supabase//linked-project.json` | JSON | post-run, linked path | -| `~/.supabase/telemetry.json` | JSON | post-run (always) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| download destination files | bytes | download (single: O_EXCL `wx`; recursive: O_TRUNC `w`) | +| download destination parent dirs | dir | recursive download (`mkdir -p`) | +| `/supabase/.temp/linked-project.json` | JSON | post-run, linked path (`--local` false) — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | +| `~/.supabase/telemetry.json` | JSON | post-run (always) | ## API Routes Auth: `apikey` always; `Authorization: Bearer ` unless the key is `sb_`-prefixed. -| Method | Path | Request body / headers | Response | -| ------ | ----------------------------------------- | ----------------------------------------------------------------------------- | ---------------- | -| `GET` | `/storage/v1/object/{path}` | — (download) | binary stream | -| `POST` | `/storage/v1/object/{path}` | file stream; `Content-Type`, `Cache-Control`, `x-upsert` (recursive only) | — | -| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` (recursive walk + dst detection) | `[{name, id?}]` | -| `GET` | `/storage/v1/bucket` | — (recursive walk to bucket root) | `[{name, id}]` | -| `POST` | `/storage/v1/bucket` | `{name, public?, file_size_limit?, allowed_mime_types?}` (auto-create on 404) | `{name}` | -| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (linked, Management API) | service-role key | +| Method | Path | Request body / headers | Response | +| ------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| `GET` | `/storage/v1/object/{path}` | — (download) | binary stream | +| `POST` | `/storage/v1/object/{path}` | file stream; `Content-Type`, `Cache-Control`, `x-upsert` (recursive only) | — | +| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` (recursive walk + dst detection) | `[{name, id?}]` | +| `GET` | `/storage/v1/bucket` | — (recursive walk to bucket root) | `[{name, id}]` | +| `POST` | `/storage/v1/bucket` | `{name, public?, file_size_limit?, allowed_mime_types?}` (auto-create on 404) | `{name}` | +| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (linked, Management API; skipped when `SUPABASE_AUTH_SERVICE_ROLE_KEY` is set to a non-empty value in the shell env) | service-role key | +| `GET` | `/v1/projects/{ref}` | — (linked, Management API; best-effort cache fill after the ref resolves, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored) | `ref`, `name`, `organization_id`, `organization_slug` | ## Environment Variables -`SUPABASE_AUTH_SERVICE_ROLE_KEY`, `SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_SERVICES_HOSTNAME`, -and the `SUPABASE_API_*` override family are legacy backend only; `SUPABASE_ACCESS_TOKEN`, +`SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_SERVICES_HOSTNAME`, and the `SUPABASE_API_*` +override family are legacy backend only. `SUPABASE_AUTH_SERVICE_ROLE_KEY` is legacy backend only +for `--local`; on the linked path a non-empty shell value skips the api-keys call under either +backend. `SUPABASE_ACCESS_TOKEN`, `SUPABASE_PROJECT_ID`, and `SUPABASE_EXPERIMENTAL_STACK` (backend selection) apply to either backend — same roles as `storage ls`. `SUPABASE_PROJECT_ID`'s linked-ref resolution is superseded by `--project-ref` when set. @@ -99,6 +103,8 @@ backend — same roles as `storage ls`. resolution used above (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies `--linked`: passing it with `--local` is a hard error rather than a silently discarded flag. +- **`--local=false`** / `--no-local` target the linked project: routing follows the parsed + `--local` value, and `--linked` is not read for routing. - Single upload does NOT send `x-upsert`; recursive upload sets it. - `--content-type` overrides the sniffed type; an explicit value is still refined when it is a generic `text/plain`. diff --git a/apps/cli/src/commands/storage/cp/cp.command.ts b/apps/cli/src/commands/storage/cp/cp.command.ts index f06cc84eec..da27d26579 100644 --- a/apps/cli/src/commands/storage/cp/cp.command.ts +++ b/apps/cli/src/commands/storage/cp/cp.command.ts @@ -5,6 +5,8 @@ import type * as CliCommand from "effect/cli/Command"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; @@ -12,6 +14,7 @@ import { storageGatewayRuntimeLayer } from "../../../command-internal/storage-ru import { storageInvalidJobsMessage } from "../storage.errors.ts"; import { parseUintBase0 } from "../../../shared/config/parse-uint.ts"; import { + STORAGE_PERMISSIONS, StorageLinkedFlagDef, StorageLocalFlagDef, StorageProjectRefFlagDef, @@ -87,6 +90,12 @@ export const storageCpCommand = Command.make("cp", config).pipe( description: "Download a directory from storage", }, ]), + withPermissions( + compose(STORAGE_PERMISSIONS, { + operations: [], + noApiEffectFlags: ["cache-control", "content-type", "jobs"], + }), + ), Command.withHandler((flags) => Effect.gen(function* () { // Gate before the mutex check below; see requireExperimental's doc comment for why. A diff --git a/apps/cli/src/commands/storage/cp/cp.integration.test.ts b/apps/cli/src/commands/storage/cp/cp.integration.test.ts index eddb2d36c3..f5cda0da41 100644 --- a/apps/cli/src/commands/storage/cp/cp.integration.test.ts +++ b/apps/cli/src/commands/storage/cp/cp.integration.test.ts @@ -2,7 +2,8 @@ import { describe, expect, it } from "@effect/vitest"; import { Cause, Effect, Exit, FileSystem, Option, Path, Predicate } from "effect"; import { setupStorage, STORAGE_TEST_JWT_SECRET } from "../../../../tests/helpers/storage.ts"; -import { VALID_REF, useTempWorkdir } from "../../../../tests/helpers/command-mocks.ts"; +import { VALID_REF, useTempWorkdir, withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { StackStorageCapabilityError } from "../../../command-internal/stack-storage.ts"; import { generateLocalJwt } from "../../../command-internal/local-jwt.ts"; import { StorageUnsupportedOperationError } from "../storage.errors.ts"; @@ -12,6 +13,9 @@ import type { StorageCpFlags } from "./cp.command.ts"; const BUCKET = "/storage/v1/bucket"; const OBJECT = (p: string) => `/storage/v1/object/${p}`; const LIST = (bucket: string) => `/storage/v1/object/list/${bucket}`; +const REVEALED_API_KEYS = `GET https://api.supabase.com/v1/projects/${VALID_REF}/api-keys?reveal=true`; +const PROJECT_LOOKUP = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; +const CACHE_FILE = "supabase/.temp/linked-project.json"; function cpFlags(opts: { src: string; @@ -682,6 +686,43 @@ describe("storage cp", () => { expect(yield* fs.readFileString(path.join(dst, "b.txt"))).toBe("b-content"); }).pipe(Effect.provide(layer)); }); + it.effect("fetches the revealed api keys and fills the cache through the real layers", () => + withEnvVar( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + undefined, + Effect.gen(function* () { + const { layer, requests, managementRequests } = setupStorage(tmp.current, { + recordManagementApi: true, + routes: [{ method: "POST", match: OBJECT("private/readme.md"), body: {} }], + }); + yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fs.writeFileString(path.join(tmp.current, "readme.md"), "hello world"); + yield* storageCp( + cpFlags({ + src: path.join(tmp.current, "readme.md"), + dst: "ss:///private/readme.md", + local: false, + jobs: 1, + }), + ); + expect( + managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), + ).toEqual([REVEALED_API_KEYS, PROJECT_LOOKUP]); + const upload = requests.find((r) => r.url.includes(OBJECT("private/readme.md"))); + expect(upload?.method).toBe("POST"); + expect(upload?.url.startsWith(`https://${VALID_REF}.supabase.co`)).toBe(true); + expect(yield* fs.exists(path.join(tmp.current, CACHE_FILE))).toBe(true); + assertPermissionDrift({ + command: "storage cp", + activeFlags: { local: false, jobs: "1" }, + requests: managementRequests, + }); + }).pipe(Effect.provide(layer)); + }), + ), + ); }); describe("stack backend", () => { diff --git a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md index 3feb26a624..beb0a3996c 100644 --- a/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/ls/SIDE_EFFECTS.md @@ -10,30 +10,32 @@ credential, capability-state, and error behavior. ## Files Read -| Path | Format | When | -| --------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (local creds/baseUrl; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | -| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | -| `/supabase/.env*`, `/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | +| Path | Format | When | +| ---------------------------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (local creds/baseUrl; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | +| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | +| `/supabase/.temp/project-ref` | plain text | linked path, when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/linked-project.json` | JSON | linked path, existence check before the cache fill | +| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | +| `/supabase/.env*`, `/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | ## Files Written -| Path | Format | When | -| ---------------------------------------- | ------ | --------------------------------- | -| `~/.supabase//linked-project.json` | JSON | post-run, linked path (ref cache) | -| `~/.supabase/telemetry.json` | JSON | post-run (always) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | post-run, linked path (`--local` false) — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | +| `~/.supabase/telemetry.json` | JSON | post-run (always) | ## API Routes Auth: `apikey` header always; `Authorization: Bearer ` unless the key is `sb_`-prefixed. -| Method | Path | Request body | Response (used) | -| ------ | ----------------------------------------- | --------------------------------------- | ------------------------------- | -| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` | `[{name, id?}]` (id null ⇒ dir) | -| `GET` | `/storage/v1/bucket` | — | `[{name, id}]` | -| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (linked, Management API) | api-key list → service-role key | +| Method | Path | Request body | Response (used) | +| ------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` | `[{name, id?}]` (id null ⇒ dir) | +| `GET` | `/storage/v1/bucket` | — | `[{name, id}]` | +| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (linked, Management API; skipped when `SUPABASE_AUTH_SERVICE_ROLE_KEY` is set to a non-empty value in the shell env) | api-key list → service-role key | +| `GET` | `/v1/projects/{ref}` | — (linked, Management API; best-effort cache fill after the ref resolves, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored) | `ref`, `name`, `organization_id`, `organization_slug` | ## Environment Variables @@ -100,6 +102,8 @@ No custom storage telemetry events. resolution used above (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies `--linked`: passing it with `--local` is a hard error rather than a silently discarded flag. +- **`--local=false`** / `--no-local` target the linked project: routing follows the parsed + `--local` value, and `--linked` is not read for routing. - `--local` / `--linked` are mutually exclusive; `--local` routes to the local stack, otherwise the linked project is used. They are declared **per-leaf** (not as `storage`-group scoped globals) because Effect CLI requires global-flag names to be diff --git a/apps/cli/src/commands/storage/ls/ls.command.ts b/apps/cli/src/commands/storage/ls/ls.command.ts index 3a55a41c4f..3d6c2f5c27 100644 --- a/apps/cli/src/commands/storage/ls/ls.command.ts +++ b/apps/cli/src/commands/storage/ls/ls.command.ts @@ -5,11 +5,13 @@ import type * as CliCommand from "effect/cli/Command"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { storageGatewayRuntimeLayer } from "../../../command-internal/storage-runtime.layer.ts"; import { + STORAGE_PERMISSIONS, StorageLinkedFlagDef, StorageLocalFlagDef, StorageProjectRefFlagDef, @@ -43,6 +45,7 @@ export const storageLsCommand = Command.make("ls", config).pipe( description: "List objects at a storage path", }, ]), + withPermissions(STORAGE_PERMISSIONS), Command.withHandler((flags) => Effect.gen(function* () { // Gate before the mutex check below; see requireExperimental's doc comment for why. diff --git a/apps/cli/src/commands/storage/ls/ls.integration.test.ts b/apps/cli/src/commands/storage/ls/ls.integration.test.ts index 4d20b423f0..04c57edbc2 100644 --- a/apps/cli/src/commands/storage/ls/ls.integration.test.ts +++ b/apps/cli/src/commands/storage/ls/ls.integration.test.ts @@ -4,6 +4,7 @@ import { Cause, Effect, Exit, FileSystem, Option, Path, Predicate } from "effect import { VALID_REF, useTempWorkdir, withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; import { setupStorage, STORAGE_TEST_JWT_SECRET } from "../../../../tests/helpers/storage.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { StackStorageCapabilityError, StackStorageUnavailableError, @@ -15,6 +16,9 @@ import { generateLocalJwt } from "../../../command-internal/local-jwt.ts"; const BUCKET = "/storage/v1/bucket"; const LIST = (bucket: string) => `/storage/v1/object/list/${bucket}`; +const REVEALED_API_KEYS = `GET https://api.supabase.com/v1/projects/${VALID_REF}/api-keys?reveal=true`; +const PROJECT_LOOKUP = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; +const CACHE_FILE = "supabase/.temp/linked-project.json"; const writeAncestorConfig = Effect.fnUntraced(function* (root: string, toml: string) { const fs = yield* FileSystem.FileSystem; @@ -469,6 +473,119 @@ project_id = "env(STAGING_REF)" }).pipe(Effect.provide(BunServices.layer)); }, ); + it.effect("fetches the revealed api keys and fills the cache through the real layers", () => + withEnvVar( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + undefined, + Effect.gen(function* () { + const { layer, out, requests, managementRequests } = setupStorage(tmp.current, { + recordManagementApi: true, + routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], + }); + const fs = yield* FileSystem.FileSystem; + yield* storageLs(lsFlags({ local: false })).pipe(Effect.provide(layer)); + expect( + managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), + ).toEqual([REVEALED_API_KEYS, PROJECT_LOOKUP]); + expect(out.stdoutText).toBe("remote/\n"); + expect(requests[0]?.url.startsWith(`https://${VALID_REF}.supabase.co`)).toBe(true); + expect(yield* fs.exists(`${tmp.current}/${CACHE_FILE}`)).toBe(true); + assertPermissionDrift({ + command: "storage ls", + activeFlags: { local: false }, + requests: managementRequests, + }); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); + + it.effect("asks for no api keys when SUPABASE_AUTH_SERVICE_ROLE_KEY is set", () => + withEnvVar( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + "env-service-role-key", + Effect.gen(function* () { + const { layer, requests, managementRequests } = setupStorage(tmp.current, { + recordManagementApi: true, + routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], + }); + yield* storageLs(lsFlags({ local: false })).pipe(Effect.provide(layer)); + expect(requests[0]?.headers["apikey"]).toBe("env-service-role-key"); + expect(managementRequests.map(({ method, url }) => `${method} ${url}`)).toEqual([ + PROJECT_LOOKUP, + ]); + assertPermissionDrift({ + command: "storage ls", + activeFlags: { local: false }, + requests: managementRequests, + }); + }), + ), + ); + + it.effect("makes no Management API request for --local", () => + withEnvVar( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + undefined, + Effect.gen(function* () { + const { layer, out, requests, managementRequests } = setupStorage(tmp.current, { + toml: 'project_id = "test"\n[api]\nport = 65432\n', + recordManagementApi: true, + routes: [{ method: "GET", match: BUCKET, body: [{ name: "test", id: "test" }] }], + }); + const fs = yield* FileSystem.FileSystem; + yield* storageLs(lsFlags({ local: true })).pipe(Effect.provide(layer)); + expect(out.stdoutText).toBe("test/\n"); + expect(requests).toHaveLength(1); + expect(requests[0]?.url.startsWith("http://127.0.0.1:65432")).toBe(true); + expect(managementRequests).toEqual([]); + expect(yield* fs.exists(`${tmp.current}/${CACHE_FILE}`)).toBe(false); + assertPermissionDrift({ + command: "storage ls", + activeFlags: { local: true }, + requests: managementRequests, + }); + expect(() => + assertPermissionDrift({ + command: "storage ls", + activeFlags: { local: true }, + requests: [ + { + method: "GET", + url: `https://api.supabase.com/v1/projects/${VALID_REF}/api-keys`, + urlParams: "reveal=true", + }, + ], + }), + ).toThrow(/condition doesn't hold/); + }).pipe(Effect.provide(BunServices.layer)), + ), + ); + + it.effect("--linked=false still targets the linked project", () => + withEnvVar( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + undefined, + Effect.gen(function* () { + const { layer, out, requests, managementRequests } = setupStorage(tmp.current, { + recordManagementApi: true, + routes: [{ method: "GET", match: BUCKET, body: [{ name: "remote", id: "remote" }] }], + }); + yield* storageLs({ ...lsFlags({ local: false }), linked: false }).pipe( + Effect.provide(layer), + ); + expect(out.stdoutText).toBe("remote/\n"); + expect(requests[0]?.url.startsWith(`https://${VALID_REF}.supabase.co`)).toBe(true); + expect( + managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), + ).toEqual([REVEALED_API_KEYS, PROJECT_LOOKUP]); + assertPermissionDrift({ + command: "storage ls", + activeFlags: { linked: false, local: false }, + requests: managementRequests, + }); + }), + ), + ); }); describe("stack backend", () => { diff --git a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md index 74d6a284e0..dc1bebaaa4 100644 --- a/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/mv/SIDE_EFFECTS.md @@ -12,35 +12,39 @@ credential, capability-state, and error behavior. ## Files Read -| Path | Format | When | -| --------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | -| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | -| `/supabase/.env*`, `/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | +| Path | Format | When | +| ---------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | +| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | +| `/supabase/.temp/project-ref` | plain text | linked path, when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/linked-project.json` | JSON | linked path, existence check before the cache fill | +| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | +| `/supabase/.env*`, `/.env*` | dotenv | local path: read on both backends for `env(VAR)` interpolation while loading `config.toml`; on the legacy backend also to resolve the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | ## Files Written -| Path | Format | When | -| ---------------------------------------- | ------ | --------------------- | -| `~/.supabase//linked-project.json` | JSON | post-run, linked path | -| `~/.supabase/telemetry.json` | JSON | post-run (always) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | post-run, linked path (`--local` false) — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | +| `~/.supabase/telemetry.json` | JSON | post-run (always) | ## API Routes Auth: `apikey` always; `Authorization: Bearer ` unless the key is `sb_`-prefixed. -| Method | Path | Request body | Response | -| ------ | ----------------------------------------- | ------------------------------------------------------------ | ---------------- | -| `POST` | `/storage/v1/object/move` | `{bucketId, sourceKey, destinationKey}` | `{message}` | -| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` (recursive fallback) | `[{name, id?}]` | -| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (linked, Management API) | service-role key | +| Method | Path | Request body | Response | +| ------ | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| `POST` | `/storage/v1/object/move` | `{bucketId, sourceKey, destinationKey}` | `{message}` | +| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` (recursive fallback) | `[{name, id?}]` | +| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (linked, Management API; skipped when `SUPABASE_AUTH_SERVICE_ROLE_KEY` is set to a non-empty value in the shell env) | service-role key | +| `GET` | `/v1/projects/{ref}` | — (linked, Management API; best-effort cache fill after the ref resolves, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored) | `ref`, `name`, `organization_id`, `organization_slug` | ## Environment Variables -`SUPABASE_AUTH_SERVICE_ROLE_KEY`, `SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_SERVICES_HOSTNAME`, -and the `SUPABASE_API_*` override family are legacy backend only; `SUPABASE_ACCESS_TOKEN`, +`SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_SERVICES_HOSTNAME`, and the `SUPABASE_API_*` +override family are legacy backend only. `SUPABASE_AUTH_SERVICE_ROLE_KEY` is legacy backend only +for `--local`; on the linked path a non-empty shell value skips the api-keys call under either +backend. `SUPABASE_ACCESS_TOKEN`, `SUPABASE_PROJECT_ID`, and `SUPABASE_EXPERIMENTAL_STACK` (backend selection) apply to either backend — same roles as `storage ls`. `SUPABASE_PROJECT_ID`'s linked-ref resolution is superseded by `--project-ref` when set. @@ -94,6 +98,8 @@ backend — same roles as `storage ls`. resolution used above (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies `--linked`: passing it with `--local` is a hard error rather than a silently discarded flag. +- **`--local=false`** / `--no-local` target the linked project: routing follows the parsed + `--local` value, and `--linked` is not read for routing. - Both `src` and `dst` must be `ss://` URLs (a stricter parse than the lenient one `cp` uses). - **Stack backend (`--local`).** The Storage endpoint comes from the selected composition's diff --git a/apps/cli/src/commands/storage/mv/mv.command.ts b/apps/cli/src/commands/storage/mv/mv.command.ts index c957574954..9b335d453e 100644 --- a/apps/cli/src/commands/storage/mv/mv.command.ts +++ b/apps/cli/src/commands/storage/mv/mv.command.ts @@ -5,11 +5,13 @@ import type * as CliCommand from "effect/cli/Command"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { storageGatewayRuntimeLayer } from "../../../command-internal/storage-runtime.layer.ts"; import { + STORAGE_PERMISSIONS, StorageLinkedFlagDef, StorageLocalFlagDef, StorageProjectRefFlagDef, @@ -41,6 +43,7 @@ export const storageMvCommand = Command.make("mv", config).pipe( description: "Recursively move a directory within storage", }, ]), + withPermissions(STORAGE_PERMISSIONS), Command.withHandler((flags) => Effect.gen(function* () { // Gate before the mutex check below; see requireExperimental's doc comment for why. diff --git a/apps/cli/src/commands/storage/mv/mv.integration.test.ts b/apps/cli/src/commands/storage/mv/mv.integration.test.ts index e7d370ab82..82a3f15541 100644 --- a/apps/cli/src/commands/storage/mv/mv.integration.test.ts +++ b/apps/cli/src/commands/storage/mv/mv.integration.test.ts @@ -1,15 +1,19 @@ import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option, Predicate } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Predicate } from "effect"; import { StackStorageCapabilityError } from "../../../command-internal/stack-storage.ts"; import { generateLocalJwt } from "../../../command-internal/local-jwt.ts"; import { setupStorage, STORAGE_TEST_JWT_SECRET } from "../../../../tests/helpers/storage.ts"; -import { VALID_REF, useTempWorkdir } from "../../../../tests/helpers/command-mocks.ts"; +import { VALID_REF, useTempWorkdir, withEnvVar } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { storageMv } from "./mv.handler.ts"; import type { StorageMvFlags } from "./mv.command.ts"; const MOVE = "/storage/v1/object/move"; const LIST = (bucket: string) => `/storage/v1/object/list/${bucket}`; +const REVEALED_API_KEYS = `GET https://api.supabase.com/v1/projects/${VALID_REF}/api-keys?reveal=true`; +const PROJECT_LOOKUP = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; +const CACHE_FILE = "supabase/.temp/linked-project.json"; function mvFlags(opts: { src: string; @@ -409,6 +413,41 @@ describe("storage mv", () => { expect(linkedCache.cached).toBe(true); }); }); + it.effect("fetches the revealed api keys and fills the cache through the real layers", () => + withEnvVar( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + undefined, + Effect.gen(function* () { + const { layer, out, requests, managementRequests } = setupStorage(tmp.current, { + recordManagementApi: true, + routes: [{ method: "POST", match: MOVE, body: { message: "Successfully moved" } }], + }); + yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* storageMv( + mvFlags({ src: "ss:///private/a", dst: "ss:///private/b", local: false }), + ); + expect( + managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), + ).toEqual([REVEALED_API_KEYS, PROJECT_LOOKUP]); + const move = requests.find((r) => r.url.includes(MOVE)); + expect(move?.url.startsWith(`https://${VALID_REF}.supabase.co`)).toBe(true); + expect(move?.body).toEqual({ + bucketId: "private", + sourceKey: "a", + destinationKey: "b", + }); + expect(out.stderrText).toContain("Successfully moved"); + expect(yield* fs.exists(`${tmp.current}/${CACHE_FILE}`)).toBe(true); + assertPermissionDrift({ + command: "storage mv", + activeFlags: { local: false }, + requests: managementRequests, + }); + }).pipe(Effect.provide(layer)); + }), + ), + ); }); describe("stack backend", () => { diff --git a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md index fd9b773c20..86dc048861 100644 --- a/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/storage/rm/SIDE_EFFECTS.md @@ -12,37 +12,41 @@ credential, capability-state, and error behavior. ## Files Read -| Path | Format | When | -| --------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | -| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | -| `~/.supabase//linked-project.json` | JSON | linked path, to resolve the project ref | -| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | -| `/supabase/.env*`, `/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878) and, on both backends, for `env(VAR)` interpolation while loading `config.toml`; on the local, legacy-backend path also the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | +| Path | Format | When | +| ---------------------------------------------- | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/config.toml` | TOML | always (local creds; `[remotes.*]` merge when linked). With an explicit `--workdir`/`SUPABASE_WORKDIR` on a LOCAL target, a missing project config is now a hard failure (`StorageMissingProjectConfigError`) rather than a fall-back to embedded defaults — the default `api.port` would otherwise point the operation at a different local stack; a REMOTE (`--project-ref`/`--linked`) target never hard-fails on this, since credential resolution there reads the Management API, not local config | +| `~/.supabase/access-token` | plain text | linked path, when `SUPABASE_ACCESS_TOKEN` unset | +| `/supabase/.temp/project-ref` | plain text | linked path, when `--project-ref` and `SUPABASE_PROJECT_ID` are both unset | +| `/supabase/.temp/linked-project.json` | JSON | linked path, existence check before the cache fill | +| local Kong TLS cert/key | PEM | legacy backend only, local + `api.enabled` + `api.tls.enabled` | +| `/supabase/.env*`, `/.env*` | dotenv | always, to resolve `SUPABASE_YES` (CLI-1878) and, on both backends, for `env(VAR)` interpolation while loading `config.toml`; on the local, legacy-backend path also the `SUPABASE_API_*` overrides for the gateway URL/TLS (#6452) and `SUPABASE_AUTH_{JWT_SECRET,SERVICE_ROLE_KEY}` for the service-role key | ## Files Written -| Path | Format | When | -| ---------------------------------------- | ------ | --------------------- | -| `~/.supabase//linked-project.json` | JSON | post-run, linked path | -| `~/.supabase/telemetry.json` | JSON | post-run (always) | +| Path | Format | When | +| ---------------------------------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | post-run, linked path (`--local` false) — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and the project lookup succeeds | +| `~/.supabase/telemetry.json` | JSON | post-run (always) | ## API Routes Auth: `apikey` always; `Authorization: Bearer ` unless the key is `sb_`-prefixed. -| Method | Path | Request body | Response | -| -------- | ----------------------------------------- | -------------------------------------------------------- | ---------------- | -| `DELETE` | `/storage/v1/object/{bucket}` | `{prefixes}` (chunked by 1000) | `[{name, ...}]` | -| `DELETE` | `/storage/v1/bucket/{id}` | — (recursive on an empty prefix) | `{message}` | -| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` (recursive walk) | `[{name, id?}]` | -| `GET` | `/storage/v1/bucket` | — (no-args + `-r`: delete all buckets) | `[{name, id}]` | -| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (linked, Management API) | service-role key | +| Method | Path | Request body | Response | +| -------- | ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------- | +| `DELETE` | `/storage/v1/object/{bucket}` | `{prefixes}` (chunked by 1000) | `[{name, ...}]` | +| `DELETE` | `/storage/v1/bucket/{id}` | — (recursive on an empty prefix) | `{message}` | +| `POST` | `/storage/v1/object/list/{bucket}` | `{prefix, search?, limit:100, offset?}` (recursive walk) | `[{name, id?}]` | +| `GET` | `/storage/v1/bucket` | — (no-args + `-r`: delete all buckets) | `[{name, id}]` | +| `GET` | `/v1/projects/{ref}/api-keys?reveal=true` | — (linked, Management API; skipped when `SUPABASE_AUTH_SERVICE_ROLE_KEY` is set to a non-empty value in the shell env) | service-role key | +| `GET` | `/v1/projects/{ref}` | — (linked, Management API; best-effort cache fill after the ref resolves, only when `.temp/linked-project.json` is absent, `.temp/project-ref` is absent or names the same ref, and a token is available; failures are ignored) | `ref`, `name`, `organization_id`, `organization_slug` | ## Environment Variables -`SUPABASE_AUTH_SERVICE_ROLE_KEY`, `SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_SERVICES_HOSTNAME`, -and the `SUPABASE_API_*` override family are legacy backend only; `SUPABASE_ACCESS_TOKEN`, +`SUPABASE_AUTH_JWT_SECRET`, `SUPABASE_SERVICES_HOSTNAME`, and the `SUPABASE_API_*` +override family are legacy backend only. `SUPABASE_AUTH_SERVICE_ROLE_KEY` is legacy backend only +for `--local`; on the linked path a non-empty shell value skips the api-keys call under either +backend. `SUPABASE_ACCESS_TOKEN`, `SUPABASE_PROJECT_ID`, `SUPABASE_EXPERIMENTAL_STACK` (backend selection), and `SUPABASE_YES` (auto-confirm) apply to either backend — read from the shell env OR the project `.env`/`.env.local`/`.env.[.local]` files @@ -107,6 +111,8 @@ Requires `--yes`/`SUPABASE_YES` on the same terms as `json`. resolution used above (flag > `SUPABASE_PROJECT_ID` > `.temp/project-ref`). It never implies `--linked`: passing it with `--local` is a hard error rather than a silently discarded flag. +- **`--local=false`** / `--no-local` target the linked project: routing follows the parsed + `--local` value, and `--linked` is not read for routing. - Validation (missing bucket, missing `-r` for a directory) runs before any network call; the no-args missing-`-r` error runs after the client is built, and keeps beating the confirmation refusal in every output mode. diff --git a/apps/cli/src/commands/storage/rm/rm.command.ts b/apps/cli/src/commands/storage/rm/rm.command.ts index c8605a3175..247efac77a 100644 --- a/apps/cli/src/commands/storage/rm/rm.command.ts +++ b/apps/cli/src/commands/storage/rm/rm.command.ts @@ -4,12 +4,14 @@ import { Argument, Command, Flag } from "effect/cli"; import { withCliConfigFlags } from "../../../config/cli-config-flags.ts"; import { cliConfigValuesLayer } from "../../../config/cli-config-values.layer.ts"; import { CliArgs } from "../../../shared/cli/cli-args.service.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { stdinLayer } from "../../../shared/runtime/stdin.layer.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { storageGatewayRuntimeLayer } from "../../../command-internal/storage-runtime.layer.ts"; import { + STORAGE_PERMISSIONS, StorageLinkedFlagDef, StorageLocalFlagDef, StorageProjectRefFlagDef, @@ -45,6 +47,7 @@ export const storageRmCommand = Command.make("rm", config).pipe( description: "Remove multiple files from storage", }, ]), + withPermissions(STORAGE_PERMISSIONS), Command.withHandler((flags) => Effect.gen(function* () { // Gate before the mutex check below; see requireExperimental's doc comment for why. diff --git a/apps/cli/src/commands/storage/rm/rm.integration.test.ts b/apps/cli/src/commands/storage/rm/rm.integration.test.ts index d67c053e52..f3a2eb0f0d 100644 --- a/apps/cli/src/commands/storage/rm/rm.integration.test.ts +++ b/apps/cli/src/commands/storage/rm/rm.integration.test.ts @@ -14,6 +14,7 @@ import { withConfigEnv, withEnvVar, } from "../../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../../tests/helpers/permission-drift.ts"; import { storageRm } from "./rm.handler.ts"; const writeAncestorConfig = Effect.fnUntraced(function* (root: string, toml: string) { @@ -28,6 +29,9 @@ const BUCKET = "/storage/v1/bucket"; const DELETE_OBJECT = (bucket: string) => `/storage/v1/object/${bucket}`; const DELETE_BUCKET = (bucket: string) => `/storage/v1/bucket/${bucket}`; const LIST = (bucket: string) => `/storage/v1/object/list/${bucket}`; +const REVEALED_API_KEYS = `GET https://api.supabase.com/v1/projects/${VALID_REF}/api-keys?reveal=true`; +const PROJECT_LOOKUP = `GET https://api.supabase.com/v1/projects/${VALID_REF}`; +const CACHE_FILE = "supabase/.temp/linked-project.json"; function prefixesOf(body: unknown): ReadonlyArray | undefined { return Predicate.hasProperty(body, "prefixes") && Array.isArray(body.prefixes) @@ -871,6 +875,44 @@ describe("storage rm", () => { expect(success?.data?.["buckets_deleted"]).toEqual([]); }); }); + it.effect("fetches the revealed api keys and fills the cache through the real layers", () => + withEnvVar( + "SUPABASE_AUTH_SERVICE_ROLE_KEY", + undefined, + Effect.gen(function* () { + const { layer, out, requests, managementRequests } = setupStorage(tmp.current, { + recordManagementApi: true, + yes: true, + routes: [ + { method: "DELETE", match: DELETE_OBJECT("private"), body: [{ name: "a.pdf" }] }, + ], + }); + yield* Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + yield* storageRm({ + files: ["ss:///private/a.pdf"], + recursive: false, + linked: true, + local: false, + projectRef: Option.none(), + }); + expect( + managementRequests.map(({ method, urlWithParams }) => `${method} ${urlWithParams}`), + ).toEqual([REVEALED_API_KEYS, PROJECT_LOOKUP]); + const del = requests.find((r) => r.method === "DELETE"); + expect(del?.url.startsWith(`https://${VALID_REF}.supabase.co`)).toBe(true); + expect(del?.body).toEqual({ prefixes: ["a.pdf"] }); + expect(out.stderrText).toContain("Deleting objects: [a.pdf]"); + expect(yield* fs.exists(`${tmp.current}/${CACHE_FILE}`)).toBe(true); + assertPermissionDrift({ + command: "storage rm", + activeFlags: { local: false }, + requests: managementRequests, + }); + }).pipe(Effect.provide(layer)); + }), + ), + ); }); describe("stack backend", () => { diff --git a/apps/cli/src/commands/storage/storage.flags.ts b/apps/cli/src/commands/storage/storage.flags.ts index 785377cf77..2407c59d07 100644 --- a/apps/cli/src/commands/storage/storage.flags.ts +++ b/apps/cli/src/commands/storage/storage.flags.ts @@ -1,7 +1,10 @@ import { Effect } from "effect"; import { Flag } from "effect/cli"; +import { compose, withCondition } from "../../command-internal/command-permissions/model.ts"; import { changedLinkedLocalFlags } from "../../command-internal/db-target-flags.ts"; +import { storageCredentials } from "../../command-internal/storage-credentials.ts"; +import { linkedProjectCache } from "../../telemetry/linked-project-cache.layer.ts"; import { StorageMutuallyExclusiveFlagsError } from "./storage.errors.ts"; /** @@ -28,6 +31,12 @@ export const StorageProjectRefFlagDef = Flag.String("project-ref").pipe( Flag.optional, ); +/** The Management API calls every `storage` subcommand makes on the linked project. */ +export const STORAGE_PERMISSIONS = compose( + { operations: [], noApiEffectFlags: ["linked", "project-ref", "recursive"] }, + withCondition(compose(storageCredentials, linkedProjectCache), { flag: "local", is: false }), +); + /** * Mutual-exclusion check for `--linked`/`--local`, rejected before the handler body * so it never fires `cli_command_executed`. Each leaf must call this before diff --git a/apps/cli/src/commands/telemetry/disable/disable.command.ts b/apps/cli/src/commands/telemetry/disable/disable.command.ts index d8795b07f7..ace0dafaa6 100644 --- a/apps/cli/src/commands/telemetry/disable/disable.command.ts +++ b/apps/cli/src/commands/telemetry/disable/disable.command.ts @@ -4,6 +4,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { telemetryDisable } from "./disable.handler.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = {}; export type TelemetryDisableFlags = CliCommand.Command.Config.Infer; @@ -11,6 +12,7 @@ export type TelemetryDisableFlags = CliCommand.Command.Config.Infer // `cli_command_executed` fires based on the consent value read at layer-construction // time, before this handler mutates it — so `disable` still fires when telemetry was diff --git a/apps/cli/src/commands/telemetry/enable/enable.command.ts b/apps/cli/src/commands/telemetry/enable/enable.command.ts index dd1eba8da3..e868fef81e 100644 --- a/apps/cli/src/commands/telemetry/enable/enable.command.ts +++ b/apps/cli/src/commands/telemetry/enable/enable.command.ts @@ -4,6 +4,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { telemetryEnable } from "./enable.handler.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = {}; export type TelemetryEnableFlags = CliCommand.Command.Config.Infer; @@ -11,6 +12,7 @@ export type TelemetryEnableFlags = CliCommand.Command.Config.Infer // `cli_command_executed` fires based on the consent value read at layer-construction // time: silent when enabling from a disabled state, since the snapshot is `false`. diff --git a/apps/cli/src/commands/telemetry/status/status.command.ts b/apps/cli/src/commands/telemetry/status/status.command.ts index 20ca21cf00..a93ee1ab98 100644 --- a/apps/cli/src/commands/telemetry/status/status.command.ts +++ b/apps/cli/src/commands/telemetry/status/status.command.ts @@ -4,6 +4,7 @@ import { withJsonErrorHandling } from "../../../shared/output/json-error-handlin import { commandRuntimeLayer } from "../../../shared/runtime/command-runtime.layer.ts"; import { withCommandTelemetry } from "../../../telemetry/command-telemetry.ts"; import { telemetryStatus } from "./status.handler.ts"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = {}; export type TelemetryStatusFlags = CliCommand.Command.Config.Infer; @@ -11,6 +12,7 @@ export type TelemetryStatusFlags = CliCommand.Command.Config.Infer telemetryStatus(flags).pipe(withCommandTelemetry({ flags }), withJsonErrorHandling), ), diff --git a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md index 2e6f79da54..46f7f048c5 100644 --- a/apps/cli/src/commands/test/db/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/test/db/SIDE_EFFECTS.md @@ -41,11 +41,11 @@ Managed stack `--local` prove runs the catalog `pg_prove` tool through the ownin ## API Routes (`--linked` only) -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ----------------------------------- | ------------ | ----------------------------------------- | --------------------------- | -| POST | `/v1/projects/{ref}/cli/login-role` | access token | `{ read_only: false }` | `{ role, password }` | -| GET | `/v1/projects/{ref}/network-bans` | access token | — | `{ banned_ipv4_addresses }` | -| DELETE | `/v1/projects/{ref}/network-bans` | access token | `{ ipv4_addresses, requester_ip: false }` | — | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------------ | ------------ | ----------------------------------------- | --------------------------- | +| POST | `/v1/projects/{ref}/cli/login-role` | access token | `{ read_only: false }` | `{ role, password }` | +| POST | `/v1/projects/{ref}/network-bans/retrieve` | access token | — | `{ banned_ipv4_addresses }` | +| DELETE | `/v1/projects/{ref}/network-bans` | access token | `{ ipv4_addresses, requester_ip: false }` | — | ## Environment Variables diff --git a/apps/cli/src/commands/unlink/unlink.command.ts b/apps/cli/src/commands/unlink/unlink.command.ts index 649ea8371e..40f55e3078 100644 --- a/apps/cli/src/commands/unlink/unlink.command.ts +++ b/apps/cli/src/commands/unlink/unlink.command.ts @@ -6,6 +6,7 @@ import { commandSettingsLayer } from "../../config/command-settings.layer.ts"; import { debugLoggerLayer } from "../../command-internal/debug-logger.layer.ts"; import { telemetryStateLayer } from "../../telemetry/telemetry-state.layer.ts"; import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { unlink } from "./unlink.handler.ts"; @@ -30,6 +31,7 @@ const unlinkRuntimeLayer = Layer.mergeAll( export const unlinkCommand = Command.make("unlink").pipe( Command.withDescription("Unlink a Supabase project."), Command.withShortDescription("Unlink a Supabase project"), + withPermissions({ operations: [] }), Command.withHandler(() => unlink().pipe(withCommandTelemetry(), withJsonErrorHandling)), Command.provide(unlinkRuntimeLayer), ); diff --git a/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md b/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md index 62ab4db33b..20da155639 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/activate/SIDE_EFFECTS.md @@ -2,32 +2,37 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ---------------------------------------------- | ------------ | ------------------------------ | --------------------------- | -| `POST` | `/v1/projects/{ref}/vanity-subdomain/activate` | Bearer token | `{ vanity_subdomain: string }` | `{ custom_domain: string }` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ---------------------------------------------- | ------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `POST` | `/v1/projects/{ref}/vanity-subdomain/activate` | Bearer token | `{ vanity_subdomain: string }` | `{ custom_domain: string }` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` (key `vanity_subdomain`) — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -72,12 +77,14 @@ One `result` event with the full response object. ## Notes - The `--output` flag wins over TS `--output-format` when both are provided. -- `linked-project.json` is written after ref resolution (once the `--experimental` gate is open), - even when the API call fails. A closed gate writes nothing. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is + open), even when the API call fails, but only on a cache miss with a token when + `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed + gate writes nothing. - On gated 4xx responses this command prints an upgrade suggestion and fires `cli_upgrade_suggested`. - `--desired-subdomain` is required, but only enforced in the handler (after gate → login → ref resolution) rather than at parse time, using the fixed wording `required flag(s) "desired-subdomain" not set`. The gate/login/ref errors win over the missing - flag, and telemetry and `linked-project.json` are still written on this failure. An explicit + flag, and telemetry still fires and the cache fill still runs on this failure. An explicit empty value (`--desired-subdomain ""`) passes the check and reaches the API — only presence is required, not a non-empty value. diff --git a/apps/cli/src/commands/vanity-subdomains/activate/activate.command.ts b/apps/cli/src/commands/vanity-subdomains/activate/activate.command.ts index fbec9d3ba1..6b74abfc10 100644 --- a/apps/cli/src/commands/vanity-subdomains/activate/activate.command.ts +++ b/apps/cli/src/commands/vanity-subdomains/activate/activate.command.ts @@ -2,6 +2,11 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/output-formats.ts"; @@ -33,6 +38,27 @@ export const vanitySubdomainsActivateCommand = Command.make("activate", config). "Activate a vanity subdomain for your Supabase project. This reconfigures your Supabase project to respond to requests on your vanity subdomain. After the vanity subdomain is activated, your project's auth services will no longer function on the {project-ref}.{supabase-domain} hostname.", ), Command.withShortDescription("Activate a vanity subdomain"), + withPermissions( + compose( + { + operations: [ + { + operationId: "v1-activate-vanity-subdomain-config", + kind: "required", + when: [{ flag: "desired-subdomain", present: true }], + }, + { + operationId: "v1-activate-vanity-subdomain-config", + kind: "required", + when: [{ flag: "desired-subdomain", oneOf: [""] }], + }, + ], + }, + upgradeSuggestion, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => Effect.gen(function* () { // An invalid `-o` value must win over a missing `--experimental` flag, so this diff --git a/apps/cli/src/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md b/apps/cli/src/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md index fce0b2dea2..8a25e3bf78 100644 --- a/apps/cli/src/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/check-availability/SIDE_EFFECTS.md @@ -2,32 +2,37 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | -------------------------------------------------------- | ------------ | ------------------------------ | ------------------------ | -| `POST` | `/v1/projects/{ref}/vanity-subdomain/check-availability` | Bearer token | `{ vanity_subdomain: string }` | `{ available: boolean }` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | -------------------------------------------------------- | ------------ | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `POST` | `/v1/projects/{ref}/vanity-subdomain/check-availability` | Bearer token | `{ vanity_subdomain: string }` | `{ available: boolean }` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{organization_slug}` — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/organizations/{slug}/entitlements` | Bearer token | none | `{entitlements[].feature.key, .hasAccess}` (key `vanity_subdomain`) — upgrade-gate side-call on a 4xx with no plan-gate error in the body | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -74,11 +79,13 @@ One `result` event with the full response object. ## Notes - The `--output` flag wins over TS `--output-format` when both are provided. -- `linked-project.json` is written after ref resolution (once the `--experimental` gate is open), - even when the API call fails. A closed gate writes nothing. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is + open), even when the API call fails, but only on a cache miss with a token when + `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed + gate writes nothing. - `--desired-subdomain` is required, but only enforced in the handler (after gate → login → ref resolution) rather than at parse time, using the fixed wording `required flag(s) "desired-subdomain" not set`. The gate/login/ref errors win over the missing - flag, and telemetry and `linked-project.json` are still written on this failure. An explicit + flag, and telemetry still fires and the cache fill still runs on this failure. An explicit empty value (`--desired-subdomain ""`) passes the check and reaches the API — only presence is required, not a non-empty value. diff --git a/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.command.ts b/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.command.ts index c107a041e0..ef5594cdd6 100644 --- a/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.command.ts +++ b/apps/cli/src/commands/vanity-subdomains/check-availability/check-availability.command.ts @@ -2,6 +2,11 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; +import { upgradeSuggestion } from "../../../command-internal/upgrade-suggest.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/output-formats.ts"; @@ -34,6 +39,27 @@ export const vanitySubdomainsCheckAvailabilityCommand = Command.make( ).pipe( Command.withDescription("Checks if a desired subdomain is available for use."), Command.withShortDescription("Check subdomain availability"), + withPermissions( + compose( + { + operations: [ + { + operationId: "v1-check-vanity-subdomain-availability", + kind: "required", + when: [{ flag: "desired-subdomain", present: true }], + }, + { + operationId: "v1-check-vanity-subdomain-availability", + kind: "required", + when: [{ flag: "desired-subdomain", oneOf: [""] }], + }, + ], + }, + upgradeSuggestion, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => Effect.gen(function* () { // An invalid `-o` value must win over a missing `--experimental` flag, so this diff --git a/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md b/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md index ce9582a617..bd069d41c6 100644 --- a/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/delete/SIDE_EFFECTS.md @@ -2,32 +2,35 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| -------- | ------------------------------------- | ------------ | ------------ | ---------------------- | -| `DELETE` | `/v1/projects/{ref}/vanity-subdomain` | Bearer token | none | none | +| Method | Path | Auth | Request body | Response (used fields) | +| -------- | ------------------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `DELETE` | `/v1/projects/{ref}/vanity-subdomain` | Bearer token | none | none | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -70,5 +73,7 @@ One `result` event when the `--output` flag is unset. ## Notes - The `--output` flag wins over TS `--output-format` when both are provided. -- `linked-project.json` is written after ref resolution (once the `--experimental` gate is open), - even when the API call fails. A closed gate writes nothing. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is + open), even when the API call fails, but only on a cache miss with a token when + `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed + gate writes nothing. diff --git a/apps/cli/src/commands/vanity-subdomains/delete/delete.command.ts b/apps/cli/src/commands/vanity-subdomains/delete/delete.command.ts index ba1b99fa5b..e12039cb1a 100644 --- a/apps/cli/src/commands/vanity-subdomains/delete/delete.command.ts +++ b/apps/cli/src/commands/vanity-subdomains/delete/delete.command.ts @@ -2,6 +2,10 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/output-formats.ts"; @@ -26,6 +30,15 @@ export const vanitySubdomainsDeleteCommand = Command.make("delete", config).pipe "Deletes the vanity subdomain for a project, and reverts to using the project ref for routing.", ), Command.withShortDescription("Delete the vanity subdomain"), + withPermissions( + compose( + { + operations: [{ operationId: "v1-deactivate-vanity-subdomain-config", kind: "required" }], + }, + projectRefPicker, + linkedProjectCache, + ), + ), Command.withHandler((flags) => Effect.gen(function* () { // An invalid `-o` value must win over a missing `--experimental` flag, so this diff --git a/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md b/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md index 5c1a47d575..b6d6c4798b 100644 --- a/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md +++ b/apps/cli/src/commands/vanity-subdomains/get/SIDE_EFFECTS.md @@ -2,32 +2,35 @@ ## Files Read -| Path | Format | When | -| -------------------------------------- | ------------------------- | ---------------------------------------------------------------------- | -| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | -| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset | +| Path | Format | When | +| ---------------------------------------------- | ------------------------- | -------------------------------------------------------------------------------------------------------------------- | +| `~/.supabase/access-token` | plain text (token string) | when `SUPABASE_ACCESS_TOKEN` unset and keyring unavailable | +| `/supabase/.temp/linked-project.json` | JSON | always, after the project ref resolves — `linkedProjectCache` checks it exists to decide whether to write | +| `/supabase/.temp/project-ref` | plain text (project ref) | when `--project-ref` flag and `SUPABASE_PROJECT_ID` env are both unset; also by `linkedProjectCache` on a cache miss | ## Files Written -| Path | Format | When | -| ------------------------------------------------ | ------ | ---------------------------------------------------------------------------------------------------------------------- | -| `~/.supabase//linked-project.json` | JSON | once the `--experimental` gate is open, after ref resolution, via `Effect.ensuring` — on success and failure | -| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | +| Path | Format | When | +| ---------------------------------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/supabase/.temp/linked-project.json` | JSON | once the `--experimental` gate is open, in `Effect.ensuring` after the project ref resolves, on success or failure — only when no cached file exists, `.temp/project-ref` is absent or names the same ref, a token is available, and `GET /v1/projects/{ref}` succeeds | +| `~/.supabase/telemetry.json` | JSON | once the `--experimental` gate is open, via `Effect.ensuring` — on success and failure. Not written if gate is closed. | ## API Routes -| Method | Path | Auth | Request body | Response (used fields) | -| ------ | ------------------------------------- | ------------ | ------------ | -------------------------------------------- | -| `GET` | `/v1/projects/{ref}/vanity-subdomain` | Bearer token | none | `{ status: string, custom_domain?: string }` | +| Method | Path | Auth | Request body | Response (used fields) | +| ------ | ------------------------------------- | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `GET` | `/v1/projects` | Bearer token | none | project list for the interactive picker — only when no ref comes from `--project-ref`, `SUPABASE_PROJECT_ID` or `.temp/project-ref` and stdin is a TTY with interactive output | +| `GET` | `/v1/projects/{ref}/vanity-subdomain` | Bearer token | none | `{ status: string, custom_domain?: string }` | +| `GET` | `/v1/projects/{ref}` | Bearer token | none | `{ref, name, organization_id, organization_slug}` — `linkedProjectCache` fill, best-effort, skipped when `.temp/linked-project.json` exists, `.temp/project-ref` names another ref, or no token is available | ## Environment Variables -| Variable | Purpose | Required? | -| ----------------------- | -------------------------------------------------------- | -------------------------------------------------------------- | -| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | -| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | -| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`) | -| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | +| Variable | Purpose | Required? | +| ----------------------- | -------------------------------------------------------- | --------------------------------------------------------------------------------------------- | +| `SUPABASE_ACCESS_TOKEN` | auth token (bypasses credential file/keyring lookup) | no (falls back to keyring then `~/.supabase/access-token`) | +| `SUPABASE_PROFILE` | built-in profile name or YAML file path | no (falls back to `~/.supabase/profile` -> `supabase`) | +| `SUPABASE_PROJECT_ID` | project ref fallback when `--project-ref` is unset | no (falls back to `supabase/.temp/project-ref`, then the interactive project picker on a TTY) | +| `SUPABASE_EXPERIMENTAL` | enables `--experimental`-gated commands without the flag | no (pass `--experimental` instead; one of the two is required) | ## Exit Codes @@ -74,5 +77,7 @@ One `result` event with the full response object. ## Notes - The `--output` flag wins over TS `--output-format` when both are provided. -- `linked-project.json` is written after ref resolution (once the `--experimental` gate is open), - even when the API call fails. A closed gate writes nothing. +- `.temp/linked-project.json` is written after ref resolution (once the `--experimental` gate is + open), even when the API call fails, but only on a cache miss with a token when + `GET /v1/projects/{ref}` succeeds and `.temp/project-ref` does not name another ref. A closed + gate writes nothing. diff --git a/apps/cli/src/commands/vanity-subdomains/get/get.command.ts b/apps/cli/src/commands/vanity-subdomains/get/get.command.ts index f6bb914a91..4f89708fee 100644 --- a/apps/cli/src/commands/vanity-subdomains/get/get.command.ts +++ b/apps/cli/src/commands/vanity-subdomains/get/get.command.ts @@ -2,6 +2,10 @@ import { Effect } from "effect"; import { Command, Flag } from "effect/cli"; import type * as CliCommand from "effect/cli/Command"; +import { withPermissions } from "../../../command-internal/command-permissions/command-permissions.annotation.ts"; +import { compose } from "../../../command-internal/command-permissions/model.ts"; +import { projectRefPicker } from "../../../config/project-ref.layer.ts"; +import { linkedProjectCache } from "../../../telemetry/linked-project-cache.layer.ts"; import { withJsonErrorHandling } from "../../../shared/output/json-error-handling.ts"; import { requireExperimental } from "../../../command-internal/experimental-gate.ts"; import { RESOURCE_OUTPUT_FORMATS } from "../../../command-internal/output-formats.ts"; @@ -24,6 +28,15 @@ export type VanitySubdomainsGetFlags = CliCommand.Command.Config.Infer Effect.gen(function* () { // An invalid `-o` value must win over a missing `--experimental` flag, so this diff --git a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts index 5b83db0d6c..ae2d441ad5 100644 --- a/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts +++ b/apps/cli/src/commands/vanity-subdomains/vanity-subdomains.integration.test.ts @@ -1,12 +1,13 @@ import type { V1ActivateVanitySubdomainConfigOutput, V1CheckVanitySubdomainAvailabilityOutput, + V1GetProjectOutput, V1GetVanitySubdomainConfigOutput, } from "@supabase/api/effect"; import { describe, expect, it } from "@effect/vitest"; -import { Cause, Effect, Exit, Option } from "effect"; +import { Cause, Effect, Exit, FileSystem, Option, Schema } from "effect"; -import { mockAnalytics, mockOutput } from "../../../tests/helpers/mocks.ts"; +import { mockAnalytics, mockOutput, mockTty } from "../../../tests/helpers/mocks.ts"; import { VALID_REF, buildTestRuntime, @@ -17,6 +18,11 @@ import { mockTelemetryStateTracked, useTempWorkdir, } from "../../../tests/helpers/command-mocks.ts"; +import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; +import { + projectResponse, + realLinkedProjectCache, +} from "../../../tests/helpers/permission-real-layers.ts"; import { vanitySubdomainsActivate } from "./activate/activate.handler.ts"; import { vanitySubdomainsCheckAvailability } from "./check-availability/check-availability.handler.ts"; import { vanitySubdomainsDelete } from "./delete/delete.handler.ts"; @@ -25,9 +31,17 @@ import { vanitySubdomainsGet } from "./get/get.handler.ts"; type VanityConfig = typeof V1GetVanitySubdomainConfigOutput.Type; type AvailabilityResponse = typeof V1CheckVanitySubdomainAvailabilityOutput.Type; type ActivateResponse = typeof V1ActivateVanitySubdomainConfigOutput.Type; +type ProjectResponse = typeof V1GetProjectOutput.Type; const tempRoot = useTempWorkdir("supabase-vanity-int-"); +const API_URL = "https://api.supabase.com"; + +const decodeJson = Schema.decodeEffect(Schema.fromJsonString(Schema.Unknown)); + +const requestLines = (api: ReturnType) => + api.requests.map(({ method, url }) => `${method} ${url}`); + const SAMPLE_GET: VanityConfig = { status: "custom-domain-used", custom_domain: "example.com", @@ -46,6 +60,24 @@ const SAMPLE_ACTIVATE: ActivateResponse = { custom_domain: "example.com", }; +// Schema-valid project body: the upgrade-suggest fallback decodes it with the typed client. +const SAMPLE_PROJECT: ProjectResponse = { + id: VALID_REF, + ref: VALID_REF, + organization_id: "org-123", + organization_slug: "supabase", + name: "Test Project", + region: "us-east-1", + created_at: "2026-01-01T00:00:00Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.example.com", + version: "15", + postgres_engine: "15", + release_channel: "ga", + }, +}; + type OutputFlagValue = "env" | "pretty" | "json" | "toml" | "yaml"; function runtimeWith(opts: { @@ -77,7 +109,7 @@ function gatedApi(matchWrite: (url: string) => boolean) { return jsonResponse(request, 402, {}); } if (request.method === "GET" && request.url.endsWith(`/v1/projects/${VALID_REF}`)) { - return jsonResponse(request, 200, { organization_slug: "supabase" }); + return jsonResponse(request, 200, SAMPLE_PROJECT); } if (request.method === "GET" && request.url.includes("/entitlements")) { return jsonResponse(request, 200, { @@ -96,6 +128,34 @@ function gatedApi(matchWrite: (url: string) => boolean) { }); } +// No project is configured: the picker lists projects, the command makes its own call, and the +// real linked-project cache then looks the project up. +function pickerComposition(own: { readonly status: number; readonly body: unknown }) { + const out = mockOutput({ promptSelectResponses: [VALID_REF] }); + const api = mockCommandPlatformApi({ + handler: (request) => { + if (request.method === "GET" && request.url === `${API_URL}/v1/projects`) { + return Effect.succeed(jsonResponse(request, 200, [projectResponse(VALID_REF)])); + } + if (request.method === "GET" && request.url === `${API_URL}/v1/projects/${VALID_REF}`) { + return Effect.succeed(jsonResponse(request, 200, projectResponse(VALID_REF))); + } + return Effect.succeed(jsonResponse(request, own.status, own.body)); + }, + }); + const cliSettings = mockCommandSettings({ workdir: tempRoot.current, projectId: Option.none() }); + const layer = buildTestRuntime({ + out, + api, + cliSettings, + tty: mockTty({ stdinIsTty: true }), + linkedProjectCache: realLinkedProjectCache({ httpClient: api.httpClientLayer, cliSettings }), + }); + return { out, api, layer }; +} + +const linkedProjectCachePath = () => `${tempRoot.current}/supabase/.temp/linked-project.json`; + describe("vanity-subdomains get", () => { it.effect("prints status and subdomain in text mode", () => { const out = mockOutput({ format: "text" }); @@ -107,6 +167,35 @@ describe("vanity-subdomains get", () => { expect(out.stdoutText).toBe("Status: custom-domain-used\nVanity subdomain: example.com\n"); expect(api.requests[0]?.method).toBe("GET"); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/vanity-subdomain`); + assertPermissionDrift({ + command: "vanity-subdomains get", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the picker's projects, reads the config and fills the real cache", () => { + const { out, api, layer } = pickerComposition({ status: 200, body: SAMPLE_GET }); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + expect(yield* fs.exists(linkedProjectCachePath())).toBe(false); + yield* vanitySubdomainsGet({ projectRef: Option.none() }); + expect(requestLines(api)).toEqual([ + `GET ${API_URL}/v1/projects`, + `GET ${API_URL}/v1/projects/${VALID_REF}/vanity-subdomain`, + `GET ${API_URL}/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toBe("Status: custom-domain-used\nVanity subdomain: example.com\n"); + expect(yield* decodeJson(yield* fs.readFileString(linkedProjectCachePath()))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "vanity-subdomains get", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -132,6 +221,7 @@ describe("vanity-subdomains get", () => { const exit = yield* Effect.exit(vanitySubdomainsGet({ projectRef: Option.none() })); expect(Exit.isFailure(exit)).toBe(true); expect(api.requests).toHaveLength(1); + assertPermissionDrift({ command: "vanity-subdomains get", requests: api.requests }); expect(out.stderrText).toContain("Upgrade your plan:"); expect(out.stderrText).toContain("/org/env-org/billing"); expect(analytics.captured).toEqual([ @@ -306,6 +396,40 @@ describe("vanity-subdomains check-availability", () => { `/v1/projects/${VALID_REF}/vanity-subdomain/check-availability`, ); expect(api.requests[0]?.body).toEqual({ vanity_subdomain: "example.com" }); + assertPermissionDrift({ + command: "vanity-subdomains check-availability", + activeFlags: { "desired-subdomain": "example.com" }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the picker's projects, checks availability and fills the real cache", () => { + const { out, api, layer } = pickerComposition({ status: 201, body: SAMPLE_CHECK }); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + expect(yield* fs.exists(linkedProjectCachePath())).toBe(false); + yield* vanitySubdomainsCheckAvailability({ + projectRef: Option.none(), + desiredSubdomain: Option.some("example.com"), + }); + expect(requestLines(api)).toEqual([ + `GET ${API_URL}/v1/projects`, + `POST ${API_URL}/v1/projects/${VALID_REF}/vanity-subdomain/check-availability`, + `GET ${API_URL}/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toBe("Subdomain example.com available: true\n"); + expect(yield* decodeJson(yield* fs.readFileString(linkedProjectCachePath()))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "vanity-subdomains check-availability", + activeFlags: { "desired-subdomain": "example.com" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -396,6 +520,16 @@ describe("vanity-subdomains check-availability", () => { expect(Exit.isFailure(exit)).toBe(true); expect(out.stderrText).toContain("Upgrade your plan:"); expect(analytics.captured).toHaveLength(0); + expect(requestLines(api)).toEqual([ + `POST ${API_URL}/v1/projects/${VALID_REF}/vanity-subdomain/check-availability`, + `GET ${API_URL}/v1/projects/${VALID_REF}`, + `GET ${API_URL}/v1/organizations/supabase/entitlements`, + ]); + assertPermissionDrift({ + command: "vanity-subdomains check-availability", + activeFlags: { "desired-subdomain": "example.com" }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); @@ -436,6 +570,18 @@ describe("vanity-subdomains check-availability", () => { expect(error._tag).toBe("DesiredSubdomainRequiredError"); expect(error.message).toBe('required flag(s) "desired-subdomain" not set'); expect(api.requests).toHaveLength(0); + expect(() => + assertPermissionDrift({ + command: "vanity-subdomains check-availability", + activeFlags: {}, + requests: [ + { + method: "POST", + url: `${API_URL}/v1/projects/${VALID_REF}/vanity-subdomain/check-availability`, + }, + ], + }), + ).toThrow(/condition doesn't hold/); }).pipe(Effect.provide(layer)); }); @@ -451,6 +597,12 @@ describe("vanity-subdomains check-availability", () => { }); expect(api.requests[0]?.body).toEqual({ vanity_subdomain: "" }); expect(out.stdoutText).toBe("Subdomain available: true\n"); + assertPermissionDrift({ + command: "vanity-subdomains check-availability", + activeFlags: { "desired-subdomain": "" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); }); @@ -470,6 +622,40 @@ describe("vanity-subdomains activate", () => { expect(api.requests[0]?.method).toBe("POST"); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/vanity-subdomain/activate`); expect(api.requests[0]?.body).toEqual({ vanity_subdomain: "example.com" }); + assertPermissionDrift({ + command: "vanity-subdomains activate", + activeFlags: { "desired-subdomain": "example.com" }, + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the picker's projects, activates the subdomain and fills the real cache", () => { + const { out, api, layer } = pickerComposition({ status: 201, body: SAMPLE_ACTIVATE }); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + expect(yield* fs.exists(linkedProjectCachePath())).toBe(false); + yield* vanitySubdomainsActivate({ + projectRef: Option.none(), + desiredSubdomain: Option.some("example.com"), + }); + expect(requestLines(api)).toEqual([ + `GET ${API_URL}/v1/projects`, + `POST ${API_URL}/v1/projects/${VALID_REF}/vanity-subdomain/activate`, + `GET ${API_URL}/v1/projects/${VALID_REF}`, + ]); + expect(out.stdoutText).toBe("Activated vanity subdomain at example.com\n"); + expect(yield* decodeJson(yield* fs.readFileString(linkedProjectCachePath()))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "vanity-subdomains activate", + activeFlags: { "desired-subdomain": "example.com" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); @@ -566,6 +752,16 @@ describe("vanity-subdomains activate", () => { properties: { feature_key: "vanity_subdomain", org_slug: "supabase" }, }, ]); + expect(requestLines(api)).toEqual([ + `POST ${API_URL}/v1/projects/${VALID_REF}/vanity-subdomain/activate`, + `GET ${API_URL}/v1/projects/${VALID_REF}`, + `GET ${API_URL}/v1/organizations/supabase/entitlements`, + ]); + assertPermissionDrift({ + command: "vanity-subdomains activate", + activeFlags: { "desired-subdomain": "example.com" }, + requests: api.requests, + }); }).pipe(Effect.provide(layer)); }); @@ -608,6 +804,18 @@ describe("vanity-subdomains activate", () => { expect(error._tag).toBe("DesiredSubdomainRequiredError"); expect(error.message).toBe('required flag(s) "desired-subdomain" not set'); expect(api.requests).toHaveLength(0); + expect(() => + assertPermissionDrift({ + command: "vanity-subdomains activate", + activeFlags: {}, + requests: [ + { + method: "POST", + url: `${API_URL}/v1/projects/${VALID_REF}/vanity-subdomain/activate`, + }, + ], + }), + ).toThrow(/condition doesn't hold/); }).pipe(Effect.provide(layer)); }); @@ -622,6 +830,12 @@ describe("vanity-subdomains activate", () => { desiredSubdomain: Option.some(""), }); expect(api.requests[0]?.body).toEqual({ vanity_subdomain: "" }); + assertPermissionDrift({ + command: "vanity-subdomains activate", + activeFlags: { "desired-subdomain": "" }, + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); }); @@ -637,6 +851,35 @@ describe("vanity-subdomains delete", () => { expect(out.stderrText).toBe("Deleted vanity subdomain successfully.\n"); expect(api.requests[0]?.method).toBe("DELETE"); expect(api.requests[0]?.url).toContain(`/v1/projects/${VALID_REF}/vanity-subdomain`); + assertPermissionDrift({ + command: "vanity-subdomains delete", + requests: api.requests, + exact: true, + }); + }).pipe(Effect.provide(layer)); + }); + + it.effect("lists the picker's projects, deletes the subdomain and fills the real cache", () => { + const { out, api, layer } = pickerComposition({ status: 200, body: null }); + + return Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + expect(yield* fs.exists(linkedProjectCachePath())).toBe(false); + yield* vanitySubdomainsDelete({ projectRef: Option.none() }); + expect(requestLines(api)).toEqual([ + `GET ${API_URL}/v1/projects`, + `DELETE ${API_URL}/v1/projects/${VALID_REF}/vanity-subdomain`, + `GET ${API_URL}/v1/projects/${VALID_REF}`, + ]); + expect(out.stderrText).toContain("Deleted vanity subdomain successfully.\n"); + expect(yield* decodeJson(yield* fs.readFileString(linkedProjectCachePath()))).toMatchObject({ + ref: VALID_REF, + }); + assertPermissionDrift({ + command: "vanity-subdomains delete", + requests: api.requests, + exact: true, + }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/commands/whoami/whoami.command.ts b/apps/cli/src/commands/whoami/whoami.command.ts index 3bb8c56210..f31aab800e 100644 --- a/apps/cli/src/commands/whoami/whoami.command.ts +++ b/apps/cli/src/commands/whoami/whoami.command.ts @@ -6,6 +6,7 @@ import { managementApiRuntimeLayer } from "../../command-internal/management-api import { withJsonErrorHandling } from "../../shared/output/json-error-handling.ts"; import { withCommandTelemetry } from "../../telemetry/command-telemetry.ts"; import { whoami } from "./whoami.handler.ts"; +import { withPermissions } from "../../command-internal/command-permissions/command-permissions.annotation.ts"; const config = {}; export type WhoamiFlags = CliCommand.Command.Config.Infer; @@ -32,6 +33,7 @@ export const whoamiCommand = Command.make("whoami", config).pipe( description: "Show the current user as JSON", }, ]), + withPermissions({ operations: [{ operationId: "v1-get-profile", kind: "required" }] }), Command.withHandler(whoamiHandler), Command.provide(managementApiRuntimeLayer(["whoami"])), ); diff --git a/apps/cli/src/commands/whoami/whoami.integration.test.ts b/apps/cli/src/commands/whoami/whoami.integration.test.ts index 8b9c6f2218..0df3c52bc4 100644 --- a/apps/cli/src/commands/whoami/whoami.integration.test.ts +++ b/apps/cli/src/commands/whoami/whoami.integration.test.ts @@ -8,6 +8,7 @@ import { InvalidOutputFormatError } from "../../command-internal/output-formats. import { commandRuntimeLayer } from "../../shared/runtime/command-runtime.layer.ts"; import { ErrorActionabilityId } from "../../shared/telemetry/error-actionability.ts"; import { mockOutput } from "../../../tests/helpers/mocks.ts"; +import { assertPermissionDrift } from "../../../tests/helpers/permission-drift.ts"; import { buildTestRuntime, mockCommandPlatformApi, @@ -83,6 +84,7 @@ describe("whoami integration", () => { body: undefined, urlParams: "", }); + assertPermissionDrift({ command: "whoami", requests: api.requests, exact: true }); }).pipe(Effect.provide(layer)); }); @@ -127,7 +129,7 @@ describe("whoami integration", () => { }); it.effect("points an unauthorized profile request at re-authenticating", () => { - const { layer } = setup({ status: 401, response: { message: "Unauthorized" } }); + const { layer, api } = setup({ status: 401, response: { message: "Unauthorized" } }); return Effect.gen(function* () { const error = findError(yield* whoami({}).pipe(Effect.exit)); expect(error).toBeInstanceOf(WhoamiUnexpectedStatusError); @@ -138,6 +140,7 @@ describe("whoami integration", () => { "Authentication failed: your access token is invalid or has expired. Run `supabase login` to re-authenticate.", ); } + assertPermissionDrift({ command: "whoami", requests: api.requests }); }).pipe(Effect.provide(layer)); }); diff --git a/apps/cli/src/config/project-ref.layer.ts b/apps/cli/src/config/project-ref.layer.ts index 0db05959ee..8cd69b3fd1 100644 --- a/apps/cli/src/config/project-ref.layer.ts +++ b/apps/cli/src/config/project-ref.layer.ts @@ -25,7 +25,7 @@ export const projectRefPicker = block("project-ref", [ kind: "required", when: [{ flag: "project-ref", present: false }], context: - "an interactive terminal, no SUPABASE_PROJECT_ID and no supabase/.temp/project-ref (`link` ignores that file)", + "an interactive terminal (stdin and stdout are TTYs, output format not json), no SUPABASE_PROJECT_ID and no supabase/.temp/project-ref (`link` ignores that file)", }, ]); diff --git a/apps/cli/src/shared/compute/compute-api.ts b/apps/cli/src/shared/compute/compute-api.ts index 29c04bcb59..7ac9ea2100 100644 --- a/apps/cli/src/shared/compute/compute-api.ts +++ b/apps/cli/src/shared/compute/compute-api.ts @@ -7,6 +7,7 @@ import { type ApiClient, } from "@supabase/api/effect"; import { Effect, Option, Schedule } from "effect"; +import { block } from "../../command-internal/command-permissions/model.ts"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientRequest from "effect/http/HttpClientRequest"; import { @@ -48,6 +49,10 @@ import { * so the caller can read it as "not deployed". */ +type ComputeResourceData = typeof V2GetAComputeInstanceOutput.Type extends { data: infer D } + ? D + : never; + /** The compute shape the API returns, flattened out of its JSON:API envelope. */ export interface ComputeRecord { readonly name: string; @@ -58,7 +63,7 @@ export interface ComputeRecord { readonly instances: number; readonly backend?: string; }; - readonly buildState: "building" | "active" | "failed"; + readonly buildState: ComputeResourceData["attributes"]["build_state"]; readonly stateReason?: string; readonly imageVersion?: string; readonly deleting?: boolean; @@ -88,10 +93,6 @@ export interface ComputeDeploySpec { readonly instances: number; } -type ComputeResourceData = typeof V2GetAComputeInstanceOutput.Type extends { data: infer D } - ? D - : never; - function toComputeRecord(data: ComputeResourceData): ComputeRecord { return { name: data.id, @@ -166,6 +167,11 @@ export const listCompute = Effect.fnUntraced(function* (api: ApiClient, projectR return decoded.data.map(toComputeRecord); }); +/** The Management API call `getCompute` makes. */ +export const computeRead = block("compute-read", [ + { operationId: "v2-get-a-compute-instance", kind: "required" }, +]); + /** One compute, or `None` when this project has no record of it — see the 404 notes above. */ export const getCompute = Effect.fnUntraced(function* ( api: ApiClient, @@ -216,7 +222,7 @@ export const createComputeUpload = Effect.fnUntraced(function* ( }); /** - * PUTs the archive straight at the presigned slot, with no Supabase credentials attached — the + * Sends the archive straight to the presigned slot (POST or PUT, as the slot says), with no Supabase credentials attached — the * signature in the URL is the authorization. That's also why `httpClientLayer` redacts query * strings before logging: under `--debug` this URL is a write-capable credential. */ diff --git a/apps/cli/src/shared/functions/deploy.ts b/apps/cli/src/shared/functions/deploy.ts index ffa38f4fab..9ac7a5697a 100644 --- a/apps/cli/src/shared/functions/deploy.ts +++ b/apps/cli/src/shared/functions/deploy.ts @@ -1777,6 +1777,9 @@ function rateLimitDelayText(milliseconds: number) { return `${Math.round(milliseconds / 1_000)}s`; } +// The command's own 429 loop is the only retry layer. +const NO_CLIENT_RETRY = { retry: { maxRetries: 0 } } as const; + const rateLimitedRequest = Effect.fnUntraced(function* ( action: string, request: () => Effect.Effect< @@ -1826,15 +1829,19 @@ const uploadFunctionSource = Effect.fn("functions.deploy.uploadFunctionSource")( ); const response = yield* rateLimitedRequest(`deploying function ${config.slug}`, () => api - .executeRaw(operationDefinitions.v1DeployAFunction, { - ref: projectRef, - slug: config.slug, - ...(bundleOnly ? { bundleOnly: true } : {}), - body: { - metadata, - file: files, + .executeRaw( + operationDefinitions.v1DeployAFunction, + { + ref: projectRef, + slug: config.slug, + ...(bundleOnly ? { bundleOnly: true } : {}), + body: { + metadata, + file: files, + }, }, - }) + NO_CLIENT_RETRY, + ) .pipe( // Read the body as text (never failing) so the status check below wins: // a non-201 with a non-JSON body, or a 201 with malformed JSON, must @@ -1870,12 +1877,35 @@ const uploadFunctionSource = Effect.fn("functions.deploy.uploadFunctionSource")( ); }); -function toBulkUpdateItem(remote: RemoteFunction | DeployFunctionResponse): BulkUpdateFunction { +function bulkUpdateStatus( + slug: string, + status: string, +): Effect.Effect { + switch (status) { + case "ACTIVE": + case "REMOVED": + case "THROTTLED": + return Effect.succeed(status); + default: + return Effect.fail( + new FunctionsApiStatusError({ + status: 200, + message: `failed to read function ${slug}: unexpected status ${status}`, + decode: true, + }), + ); + } +} + +const toBulkUpdateItem = Effect.fnUntraced(function* ( + remote: RemoteFunction | DeployFunctionResponse, +) { + const status = yield* bulkUpdateStatus(remote.slug, remote.status); return { id: remote.id, slug: remote.slug, name: remote.name, - status: remote.status, + status, version: remote.version, ...(remote.created_at === undefined ? {} : { created_at: remote.created_at }), ...(remote.verify_jwt == null ? {} : { verify_jwt: remote.verify_jwt }), @@ -1883,8 +1913,8 @@ function toBulkUpdateItem(remote: RemoteFunction | DeployFunctionResponse): Bulk ...(remote.entrypoint_path == null ? {} : { entrypoint_path: remote.entrypoint_path }), ...(remote.import_map_path == null ? {} : { import_map_path: remote.import_map_path }), ...(remote.ezbr_sha256 == null ? {} : { ezbr_sha256: remote.ezbr_sha256 }), - }; -} + } satisfies BulkUpdateFunction; +}); const bulkUpdateRemoteFunctions = Effect.fn("functions.deploy.bulkUpdateFunctions")(function* ( api: ApiClient, @@ -1895,10 +1925,14 @@ const bulkUpdateRemoteFunctions = Effect.fn("functions.deploy.bulkUpdateFunction for (let attempt = 0; attempt <= 3; attempt += 1) { const result = yield* rateLimitedRequest("bulk updating functions", () => api - .executeRaw(operationDefinitions.v1BulkUpdateFunctions, { - ref: projectRef, - body: functions.map(toBulkUpdateItem), - }) + .executeRaw( + operationDefinitions.v1BulkUpdateFunctions, + { + ref: projectRef, + body: functions, + }, + NO_CLIENT_RETRY, + ) .pipe( // Read the body as text (never failing) so the status check wins even // if the body cannot be read. @@ -2295,7 +2329,7 @@ const deployViaApi = Effect.fn("functions.deploy.viaApi")(function* ( (config) => Effect.gen(function* () { yield* output.raw(`Deploying Function: ${config.slug}\n`, "stderr"); - return toBulkUpdateItem( + return yield* toBulkUpdateItem( yield* uploadFunctionSource( api, projectRef, @@ -2409,7 +2443,7 @@ const deployViaDocker = Effect.fn("functions.deploy.viaDocker")(function* ( "stderr", ); changed.push( - toBulkUpdateItem( + yield* toBulkUpdateItem( yield* upsertBundledFunction(api, projectRef, bundled, current !== undefined), ), ); diff --git a/apps/cli/src/shared/functions/download.ts b/apps/cli/src/shared/functions/download.ts index b11afbc94a..76eed70072 100644 --- a/apps/cli/src/shared/functions/download.ts +++ b/apps/cli/src/shared/functions/download.ts @@ -7,6 +7,7 @@ import * as HttpBody from "effect/http/HttpBody"; import * as HttpClientError from "effect/http/HttpClientError"; import type * as HttpClientResponse from "effect/http/HttpClientResponse"; import { Output } from "../output/output.service.ts"; +import { block } from "../../command-internal/command-permissions/model.ts"; import { mutuallyExclusiveFlagsMessage, explicitBooleanLongFlag, @@ -763,7 +764,7 @@ const downloadBody = Effect.fnUntraced(function* ( ref: projectRef, function_slug: slug, }, - { Accept: "multipart/form-data" }, + { headers: { Accept: "multipart/form-data" } }, ) .pipe(Effect.mapError((error) => mapTransportError("failed to download function", error))); @@ -796,7 +797,7 @@ const downloadEszipBody = Effect.fnUntraced(function* ( ref: projectRef, function_slug: slug, }, - { Accept: "*/*" }, + { headers: { Accept: "*/*" } }, ) .pipe(Effect.mapError((error) => mapTransportError("failed to get function body", error))); @@ -1143,6 +1144,22 @@ function attachDownloadWrittenSoFar( : Object.assign(error, { writtenSoFar: [...downloadedSoFar] }); } +/** The Management API calls `downloadFunctions` makes: the function body, its metadata and the function list. */ +export const functionsDownload = block("functions-download", [ + { operationId: "v1-get-a-function-body", kind: "required" }, + { + operationId: "v1-get-a-function", + kind: "required", + context: + "on the server-side path (--use-api, --use-docker=false, or Docker not running), when the downloaded metadata names no entrypoint", + }, + { + operationId: "v1-list-all-functions", + kind: "required", + context: "no function name is given", + }, +]); + export const downloadFunctions = Effect.fn("functions.download")(function* < ResolveError, ResolveRequirements, diff --git a/apps/cli/src/shared/services/Dockerfile b/apps/cli/src/shared/services/Dockerfile index 563c96ccd7..730b3b8c84 100644 --- a/apps/cli/src/shared/services/Dockerfile +++ b/apps/cli/src/shared/services/Dockerfile @@ -33,11 +33,11 @@ FROM supabase/supavisor:2.9.13 AS supavisor FROM supabase/gotrue:v2.197.0 AS gotrue -FROM supabase/realtime:v2.143.4 AS realtime +FROM supabase/realtime:v2.144.0 AS realtime -FROM supabase/storage-api:v1.82.1 AS storage +FROM supabase/storage-api:v1.83.3 AS storage -FROM supabase/logflare:1.53.0 AS logflare +FROM supabase/logflare:1.50.15 AS logflare # One-shot job images (not started as long-running containers); differ's alias # is resolved via dockerfileServiceImage("differ") in commands/db/diff/pgadmin-diff.ts. FROM supabase/pgadmin-schema-diff:cli-0.0.5 AS differ diff --git a/apps/cli/src/shared/services/services.shared.ts b/apps/cli/src/shared/services/services.shared.ts index c89c9770df..b12ad117e6 100644 --- a/apps/cli/src/shared/services/services.shared.ts +++ b/apps/cli/src/shared/services/services.shared.ts @@ -219,7 +219,7 @@ const TABLE_HEADERS = ["SERVICE IMAGE", "LOCAL", "LINKED"] as const; type ProjectApiKey = { readonly name: string; - readonly type?: "legacy" | "publishable" | "secret" | null; + readonly type?: string | null; readonly api_key?: string | null; readonly secret_jwt_template?: Record | null; }; diff --git a/apps/cli/src/shared/services/services.shared.unit.test.ts b/apps/cli/src/shared/services/services.shared.unit.test.ts index 55faeb0ea9..3c3d7cef40 100644 --- a/apps/cli/src/shared/services/services.shared.unit.test.ts +++ b/apps/cli/src/shared/services/services.shared.unit.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, test } from "@effect/vitest"; +import { makeApiClient } from "@supabase/api/effect"; import { Effect, Redacted } from "effect"; import { FetchHttpClient } from "effect/http"; import { afterEach, beforeEach, vi } from "vitest"; @@ -56,9 +57,21 @@ const ACCESS_TOKEN = Redacted.make(`sbp_${"a".repeat(40)}`); const PROJECT_REF = "abcdefghijklmnopqrst"; // `fetchLinkedServiceVersions` reads the ambient HttpClient from context instead -// of self-provisioning one, so each invocation needs a concrete transport. +// of self-provisioning one, so each invocation needs a concrete transport. The API client +// skips retries so the 500 fixtures fail at once instead of backing off in real time. const runLinkedFetch = (input: Parameters[0]) => - fetchLinkedServiceVersions(input).pipe(Effect.provide(FetchHttpClient.layer)); + Effect.gen(function* () { + const api = yield* makeApiClient( + { + baseUrl: input.apiUrl, + accessToken: input.accessToken, + userAgent: input.userAgent, + headers: input.headers, + }, + { retry: { maxRetries: 0 } }, + ); + return yield* fetchLinkedServiceVersions({ ...input, api }); + }).pipe(Effect.provide(FetchHttpClient.layer)); // Tenant probes share the test server's origin, so only Management API paths are recorded. const recordManagementRequests = () => { diff --git a/apps/cli/src/telemetry/linked-project-cache.integration.test.ts b/apps/cli/src/telemetry/linked-project-cache.integration.test.ts index e8e09e4744..03ea79245e 100644 --- a/apps/cli/src/telemetry/linked-project-cache.integration.test.ts +++ b/apps/cli/src/telemetry/linked-project-cache.integration.test.ts @@ -30,9 +30,19 @@ describe("linkedProjectCacheLayer", () => { new Response( JSON.stringify({ ref: VALID_REF, + id: VALID_REF, name: "proj", organization_id: "org-1", organization_slug: "acme", + region: "us-east-1", + created_at: "2026-01-01T00:00:00Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.example.com", + version: "15", + postgres_engine: "15", + release_channel: "ga", + }, }), { status: 200, diff --git a/apps/cli/src/telemetry/linked-project-cache.layer.ts b/apps/cli/src/telemetry/linked-project-cache.layer.ts index 2f0c9d049f..caf677918f 100644 --- a/apps/cli/src/telemetry/linked-project-cache.layer.ts +++ b/apps/cli/src/telemetry/linked-project-cache.layer.ts @@ -1,6 +1,6 @@ -import { Effect, FileSystem, Layer, Option, Path, Redacted } from "effect"; +import { makeApiClient } from "@supabase/api/effect"; +import { Effect, FileSystem, Layer, Option, Path, type Redacted } from "effect"; import * as HttpClient from "effect/http/HttpClient"; -import * as HttpClientRequest from "effect/http/HttpClientRequest"; import { CommandCredentials } from "../auth/command-credentials.service.ts"; import { CommandSettings } from "../config/command-settings.service.ts"; @@ -16,27 +16,18 @@ export const linkedProjectCache = block("linked-project-cache", [ { operationId: "v1-get-project", kind: "best-effort", - context: "no cached supabase/.temp/linked-project.json", + context: + "an access token, no cached supabase/.temp/linked-project.json, and no supabase/.temp/project-ref naming a different project", }, ]); -function readString(obj: unknown, key: string): string { - if (typeof obj === "object" && obj !== null && key in obj) { - const value = (obj as Record)[key]; - return typeof value === "string" ? value : ""; - } - return ""; -} - /** * Writes `/supabase/.temp/linked-project.json` after a `--project-ref` has been * resolved. No write if the cache already exists (`supabase link` is authoritative), and any * API/filesystem/parse error is swallowed. * - * Bypasses `CommandPlatformApi`'s strict schema decode by calling the API directly with - * `HttpClient`. The generated `V1ProjectWithDatabaseResponse` schema enforces a 20-char - * project-ref length that the cli-e2e replay fixtures (which store `__PROJECT_REF__` - * placeholders) cannot satisfy; the cache only needs four string fields and doesn't validate them. + * Builds its own typed client per fill rather than consuming `CommandPlatformApi`, which + * requires a stored token at construction; this layer skips quietly when there is none. * * Also skips the write when `/supabase/.temp/project-ref` exists, is non-empty, and * names a different ref than `ref` — see the inline comment at the check itself for the failure @@ -96,27 +87,29 @@ export const linkedProjectCacheLayer = Layer.effect( ? cliSettings.accessToken : yield* credentials.getAccessToken); if (Option.isNone(tokenOpt)) return; - const token = Redacted.value(tokenOpt.value); - - const request = HttpClientRequest.get( - `${apiUrl ?? cliSettings.apiUrl}/v1/projects/${ref}`, - ).pipe( - HttpClientRequest.setHeader("Authorization", `Bearer ${token}`), - HttpClientRequest.setHeader("User-Agent", cliSettings.userAgent), - ); - const response = yield* httpClient.execute(request); - // Stitch identity from the response before the status gate, regardless of status. - yield* stitch(response); - yield* Effect.annotateCurrentSpan({ "http.response.status_code": response.status }); - if (response.status !== 200) return; - const body = yield* response.json; + const api = yield* makeApiClient( + { + baseUrl: apiUrl ?? cliSettings.apiUrl, + accessToken: tokenOpt.value, + userAgent: cliSettings.userAgent, + }, + { + transformClient: (client) => + Effect.succeed( + HttpClient.transform(client, (requestEffect) => + requestEffect.pipe(Effect.tap(stitch)), + ), + ), + }, + ).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)); + const project = yield* api.v1.getProject({ ref }, { retry: { maxRetries: 0 } }); const linked = { - ref: readString(body, "ref"), - name: readString(body, "name"), - organization_id: readString(body, "organization_id"), - organization_slug: readString(body, "organization_slug"), + ref: project.ref, + name: project.name, + organization_id: project.organization_id, + organization_slug: project.organization_slug, }; yield* fs.makeDirectory(path.dirname(cachePath), { recursive: true }); diff --git a/apps/cli/tests/e2e-sequencer.ts b/apps/cli/tests/e2e-sequencer.ts index 0ce50c1f43..aec487fedb 100644 --- a/apps/cli/tests/e2e-sequencer.ts +++ b/apps/cli/tests/e2e-sequencer.ts @@ -4,7 +4,9 @@ import { BaseSequencer, type TestSpecification } from "vitest/node"; /** Bun `--timings` file of measured e2e file durations, refreshed from CI artifacts. */ const TIMINGS_FILE = "tests/e2e-timings.json"; -const E2E_PROJECTS = new Set(["e2e", "e2e-exclusive"]); +const E2E_PROJECT = "e2e"; +const E2E_EXCLUSIVE_PROJECT = "e2e-exclusive"; +const E2E_PROJECTS = new Set([E2E_PROJECT, E2E_EXCLUSIVE_PROJECT]); /** Duration assumed for every file while no timing has been measured yet. */ export const DEFAULT_DURATION_MS = 30_000; @@ -28,31 +30,70 @@ function compareKeys(a: string, b: string): number { return a < b ? -1 : a > b ? 1 : 0; } +/** An e2e file to shard; exclusive files run one at a time after the concurrent ones. */ +export interface ShardFile { + readonly key: string; + readonly exclusive: boolean; +} + +interface ShardLoad { + readonly longestConcurrent: number; + readonly concurrentTotal: number; + readonly exclusiveTotal: number; +} + +function withFile(load: ShardLoad, duration: number, exclusive: boolean): ShardLoad { + return exclusive + ? { ...load, exclusiveTotal: load.exclusiveTotal + duration } + : { + ...load, + longestConcurrent: Math.max(load.longestConcurrent, duration), + concurrentTotal: load.concurrentTotal + duration, + }; +} + +function estimatedWall(load: ShardLoad, workers: number): number { + return Math.max(load.longestConcurrent, load.concurrentTotal / workers) + load.exclusiveTotal; +} + /** - * Splits keys into `count` shards by measured duration, longest first into the emptiest shard. - * The result depends only on the set of keys, so every shard process derives the same partition. + * Splits files into `count` shards by estimated wall time, longest first into the shard whose + * estimate stays lowest, the less loaded one on ties. Concurrent files share `workers` slots; + * exclusive files add their full duration. + * The result depends only on the set of files, so every shard process derives the same partition. */ export function packShards( - keys: readonly string[], + files: readonly ShardFile[], timings: Readonly>, count: number, + workers: number, ): string[][] { const fallback = fallbackDuration(timings); - const durations = keys - .map((key) => ({ key, duration: timings[key] ?? fallback })) + const durations = files + .map((file) => ({ ...file, duration: timings[file.key] ?? fallback })) .sort((a, b) => b.duration - a.duration || compareKeys(a.key, b.key)); - const shards = Array.from({ length: count }, () => ({ load: 0, keys: new Array() })); - for (const { key, duration } of durations) { - let target = shards[0]; + const empty: ShardLoad = { longestConcurrent: 0, concurrentTotal: 0, exclusiveTotal: 0 }; + const shards = Array.from({ length: count }, () => ({ keys: new Array(), load: empty })); + for (const { key, exclusive, duration } of durations) { + let target: + | { shard: (typeof shards)[number]; load: ShardLoad; wall: number; before: number } + | undefined; for (const shard of shards) { - if (target === undefined || shard.load < target.load) { - target = shard; + const load = withFile(shard.load, duration, exclusive); + const wall = estimatedWall(load, workers); + const before = estimatedWall(shard.load, workers); + if ( + target === undefined || + wall < target.wall || + (wall === target.wall && before < target.before) + ) { + target = { shard, load, wall, before }; } } if (target !== undefined) { - target.load += duration; - target.keys.push(key); + target.shard.load = target.load; + target.shard.keys.push(key); } } return shards.map((shard) => shard.keys); @@ -109,7 +150,15 @@ export class E2eSequencer extends BaseSequencer { const byKey = new Map( files.map((spec) => [relative(root, spec.moduleId).replaceAll("\\", "/"), spec]), ); - const assigned = packShards([...byKey.keys()], timings, shard.count)[shard.index - 1] ?? []; + const shardFiles = [...byKey].map(([key, spec]) => ({ + key, + exclusive: spec.project.name === E2E_EXCLUSIVE_PROJECT, + })); + const maxWorkers = + files.find((spec) => spec.project.name === E2E_PROJECT)?.project.config.maxWorkers ?? 1; + // Concurrent files share one Docker daemon and slow each other, so pack against half the slots. + const workers = Math.max(1, Math.floor(maxWorkers / 2)); + const assigned = packShards(shardFiles, timings, shard.count, workers)[shard.index - 1] ?? []; return assigned.flatMap((key) => { const spec = byKey.get(key); return spec === undefined ? [] : [spec]; diff --git a/apps/cli/tests/e2e-sequencer.unit.test.ts b/apps/cli/tests/e2e-sequencer.unit.test.ts index 1826cab568..d90a7e3813 100644 --- a/apps/cli/tests/e2e-sequencer.unit.test.ts +++ b/apps/cli/tests/e2e-sequencer.unit.test.ts @@ -9,11 +9,13 @@ const files = ["src/a.e2e.test.ts", "src/b.e2e.test.ts", "src/c.e2e.test.ts", "s const sortedShards = (shards: readonly (readonly string[])[]) => shards.map((shard) => [...shard].sort()); +const concurrent = (keys: readonly string[]) => keys.map((key) => ({ key, exclusive: false })); + describe("packShards", () => { test.each([1, files.length, files.length + 3])( "partitions every file into exactly one of %i shards", (count) => { - const shards = packShards(files, { "src/a.e2e.test.ts": 100 }, count); + const shards = packShards(concurrent(files), { "src/a.e2e.test.ts": 100 }, count, 1); expect(shards).toHaveLength(count); expect(shards.flat().sort()).toEqual([...files].sort()); @@ -21,7 +23,7 @@ describe("packShards", () => { ); test("leaves trailing shards empty when there are more shards than files", () => { - const shards = packShards(["src/a.e2e.test.ts", "src/b.e2e.test.ts"], {}, 4); + const shards = packShards(concurrent(["src/a.e2e.test.ts", "src/b.e2e.test.ts"]), {}, 4, 1); expect(shards).toEqual([["src/a.e2e.test.ts"], ["src/b.e2e.test.ts"], [], []]); }); @@ -33,8 +35,8 @@ describe("packShards", () => { "src/c.e2e.test.ts": 900, }; - const forward = packShards(files, timings, 2); - const reversed = packShards([...files].reverse(), timings, 2); + const forward = packShards(concurrent(files), timings, 2, 1); + const reversed = packShards(concurrent([...files].reverse()), timings, 2, 1); expect(reversed).toEqual(forward); }); @@ -47,7 +49,7 @@ describe("packShards", () => { "src/d.e2e.test.ts": 10, }; - const shards = packShards(files, timings, 2); + const shards = packShards(concurrent(files), timings, 2, 1); expect(sortedShards(shards)).toEqual([ ["src/b.e2e.test.ts"], @@ -55,6 +57,27 @@ describe("packShards", () => { ]); }); + test("packs concurrent files beside a long one while an exclusive file runs alone", () => { + const timings = { + "src/long.e2e.test.ts": 90, + "src/exclusive.e2e.test.ts": 80, + "src/a.e2e.test.ts": 20, + "src/b.e2e.test.ts": 20, + "src/c.e2e.test.ts": 20, + }; + const shardFiles = Object.keys(timings).map((key) => ({ + key, + exclusive: key === "src/exclusive.e2e.test.ts", + })); + + const shards = packShards(shardFiles, timings, 2, 4); + + expect(sortedShards(shards)).toEqual([ + ["src/a.e2e.test.ts", "src/b.e2e.test.ts", "src/c.e2e.test.ts", "src/long.e2e.test.ts"], + ["src/exclusive.e2e.test.ts"], + ]); + }); + test("weights an unmeasured file at the median of the measured ones", () => { const timings = { "src/a.e2e.test.ts": 100, @@ -63,7 +86,7 @@ describe("packShards", () => { }; // d is unmeasured; at the median (300) it ties with b and follows it into the second shard. - const shards = packShards(files, timings, 2); + const shards = packShards(concurrent(files), timings, 2, 1); expect(sortedShards(shards)).toEqual([ ["src/a.e2e.test.ts", "src/c.e2e.test.ts"], @@ -74,13 +97,15 @@ describe("packShards", () => { test("weights every file at the default duration when nothing has been measured", () => { const allDefault = Object.fromEntries(files.map((file) => [file, DEFAULT_DURATION_MS])); - expect(packShards(files, {}, 3)).toEqual(packShards(files, allDefault, 3)); + expect(packShards(concurrent(files), {}, 3, 1)).toEqual( + packShards(concurrent(files), allDefault, 3, 1), + ); }); test("breaks duration ties by key so equal files spread deterministically", () => { const timings = { "src/c.e2e.test.ts": 50, "src/a.e2e.test.ts": 50, "src/b.e2e.test.ts": 50 }; - const shards = packShards(Object.keys(timings), timings, 3); + const shards = packShards(concurrent(Object.keys(timings)), timings, 3, 1); expect(shards).toEqual([["src/a.e2e.test.ts"], ["src/b.e2e.test.ts"], ["src/c.e2e.test.ts"]]); }); diff --git a/apps/cli/tests/helpers/command-mocks.ts b/apps/cli/tests/helpers/command-mocks.ts index 14af1df36f..316922ea33 100644 --- a/apps/cli/tests/helpers/command-mocks.ts +++ b/apps/cli/tests/helpers/command-mocks.ts @@ -592,11 +592,15 @@ export function mockCommandPlatformApi( const layer = Layer.effect( CommandPlatformApi, - makeApiClient({ - baseUrl: opts.apiUrl ?? DEFAULT_API_URL, - accessToken: opts.accessToken ?? VALID_TOKEN, - userAgent: opts.userAgent ?? DEFAULT_USER_AGENT, - }), + // Retry backoff is covered in `@supabase/api`; stubbed failures must surface without waiting. + makeApiClient( + { + baseUrl: opts.apiUrl ?? DEFAULT_API_URL, + accessToken: opts.accessToken ?? VALID_TOKEN, + userAgent: opts.userAgent ?? DEFAULT_USER_AGENT, + }, + { retry: { maxRetries: 0 } }, + ), ).pipe(Layer.provide(httpClientLayer)); const factoryLayer = Layer.succeed(CommandPlatformApiFactory, { diff --git a/apps/cli/tests/helpers/compute.ts b/apps/cli/tests/helpers/compute.ts index 4ddb16ba48..170bffa231 100644 --- a/apps/cli/tests/helpers/compute.ts +++ b/apps/cli/tests/helpers/compute.ts @@ -5,8 +5,10 @@ import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientError from "effect/http/HttpClientError"; import type * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; +import { CommandPlatformApiFactory } from "../../src/auth/command-platform-api-factory.service.ts"; import { CommandPlatformApi } from "../../src/auth/command-platform-api.service.ts"; import { CommandSettings } from "../../src/config/command-settings.service.ts"; +import { projectRefLayer } from "../../src/config/project-ref.layer.ts"; import { ProjectRefResolver } from "../../src/config/project-ref.service.ts"; import { CliArgs } from "../../src/shared/cli/cli-args.service.ts"; import { OutputFlag, YesFlag } from "../../src/command-internal/global-flags.ts"; @@ -16,6 +18,7 @@ import { mockLinkedProjectCacheLayer } from "./command-mocks.ts"; import { configValuesLayer } from "./config-values-layer.ts"; import { TelemetryState } from "../../src/telemetry/telemetry-state.service.ts"; import { mockOutput, mockProcessControl, mockRuntimeInfo, mockTty } from "./mocks.ts"; +import { realLinkedProjectCache } from "./permission-real-layers.ts"; /** * Shared scaffolding for the `supabase compute` command integration tests. @@ -180,23 +183,36 @@ function mockComputeHttp(routes: ComputeHttpRoutes) { const apiLayer = Layer.effect( CommandPlatformApi, - makeApiClient({ - baseUrl: "https://api.supabase.com", - accessToken: "test-token", - userAgent: "supabase", - headers: { - "X-Supabase-Command": "compute", - "X-Supabase-Command-Run-ID": "run-123", + // The commands' own retry schedules are under test; the client's backoff must not hide them. + makeApiClient( + { + baseUrl: "https://api.supabase.com", + accessToken: "test-token", + userAgent: "supabase", + headers: { + "X-Supabase-Command": "compute", + "X-Supabase-Command-Run-ID": "run-123", + }, }, - }), + { retry: { maxRetries: 0 } }, + ), ).pipe(Layer.provide(httpClientLayer)); return { layer: Layer.mergeAll(apiLayer, httpClientLayer), + httpClientLayer, requests, get routeKeys(): Array { return requests.map((request) => `${request.method} ${new URL(request.url).pathname}`); }, + /** The requests in the shape the permission drift helpers take. */ + get driftRequests() { + return requests.map(({ method, url, urlParams }) => ({ + method, + url, + urlParams: new URLSearchParams(urlParams).toString(), + })); + }, }; } @@ -412,6 +428,7 @@ export interface ComputeSetupOptions { * delete api`. */ readonly stdinIsTty?: boolean; + /** Picks the mock resolver's linkage; ignored with `realLayers`, where the workdir's ref file decides. */ readonly linked?: boolean; readonly promptTextResponses?: ReadonlyArray; readonly promptSelectResponses?: ReadonlyArray; @@ -432,6 +449,12 @@ export interface ComputeSetupOptions { * never fires, modelling a terminal nobody has interrupted. */ readonly signal?: "SIGINT" | "SIGTERM" | "SIGHUP"; + /** + * Runs the real `projectRefLayer` (an unlinked workdir reaches its picker) and the real + * linked-project cache, so their `GET /v1/projects` and `GET /v1/projects/{ref}` calls go + * through `routes`. Linkage then comes from the workdir's `.temp/project-ref`, not `linked`. + */ + readonly realLayers?: boolean; } /** @@ -475,6 +498,26 @@ export function setupCompute(options: ComputeSetupOptions) { const processControl = mockProcessControl( options.signal === undefined ? {} : { signal: options.signal }, ); + const tty = mockTty({ stdinIsTty: options.stdinIsTty ?? interactive, stdoutIsTty: interactive }); + const cliSettings = testCliConfigLayer(options.workdir, options.explicitWorkdir ?? false); + const projectRef = + options.realLayers === true + ? projectRefLayer.pipe( + Layer.provide( + Layer.succeed(CommandPlatformApiFactory, { + make: CommandPlatformApi.pipe(Effect.provide(http.layer)), + }), + ), + Layer.provide(cliSettings), + Layer.provide(tty), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ) + : testProjectRefLayer(options.linked !== false); + const linkedProjectCache = + options.realLayers === true + ? realLinkedProjectCache({ httpClient: http.httpClientLayer, cliSettings }) + : mockLinkedProjectCacheLayer; return { out, @@ -485,12 +528,12 @@ export function setupCompute(options: ComputeSetupOptions) { out.layer, http.layer, mockRuntimeInfo({ cwd: options.cwd ?? options.workdir }), - mockTty({ stdinIsTty: options.stdinIsTty ?? interactive, stdoutIsTty: interactive }), - testCliConfigLayer(options.workdir, options.explicitWorkdir ?? false), + tty, + cliSettings, configValuesLayer(), - testProjectRefLayer(options.linked !== false), + projectRef, telemetry.layer, - mockLinkedProjectCacheLayer, + linkedProjectCache, randomLayer, Layer.succeed( OutputFlag, diff --git a/apps/cli/tests/helpers/notebooks.ts b/apps/cli/tests/helpers/notebooks.ts index 0e6d275cbf..77e4a72d75 100644 --- a/apps/cli/tests/helpers/notebooks.ts +++ b/apps/cli/tests/helpers/notebooks.ts @@ -6,8 +6,10 @@ import type * as HttpClientError from "effect/http/HttpClientError"; import type * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import * as UrlParams from "effect/http/UrlParams"; +import { CommandPlatformApiFactory } from "../../src/auth/command-platform-api-factory.service.ts"; import { CommandPlatformApi } from "../../src/auth/command-platform-api.service.ts"; import { ProjectRefNotLinkedError } from "../../src/config/project-ref.errors.ts"; +import { projectRefLayer } from "../../src/config/project-ref.layer.ts"; import { ProjectRefResolver } from "../../src/config/project-ref.service.ts"; import { OutputFlag } from "../../src/command-internal/global-flags.ts"; import { @@ -16,7 +18,8 @@ import { mockCommandSettings, transportFailure, } from "./command-mocks.ts"; -import { mockOutput, mockContextualAnalytics, mockProcessControl } from "./mocks.ts"; +import { mockOutput, mockContextualAnalytics, mockProcessControl, mockTty } from "./mocks.ts"; +import { realLinkedProjectCache } from "./permission-real-layers.ts"; import { commandRuntimeLayer } from "../../src/shared/runtime/command-runtime.layer.ts"; /** @@ -124,10 +127,19 @@ function mockNotebooksHttp(routes: NotebooksHttpRoutes) { return { layer: Layer.mergeAll(apiLayer, httpClientLayer), + httpClientLayer, requests, get routeKeys(): Array { return requests.map((request) => `${request.method} ${new URL(request.url).pathname}`); }, + /** The requests in the shape the permission drift helpers take. */ + get driftRequests() { + return requests.map(({ method, url, query }) => ({ + method, + url, + urlParams: query.toString(), + })); + }, }; } @@ -245,6 +257,7 @@ export interface NotebooksSetupOptions { readonly workdir: string; readonly format?: "text" | "json" | "stream-json"; readonly interactive?: boolean; + /** Picks the mock resolver's linkage; ignored with `realLayers`, where the workdir's ref file decides. */ readonly linked?: boolean; /** Answers the reconciliation prompt: `"keep"`, `"copy"` or `"delete"`. */ readonly promptSelectResponses?: ReadonlyArray; @@ -253,6 +266,12 @@ export interface NotebooksSetupOptions { readonly outputFlag?: "env" | "pretty" | "json" | "toml" | "yaml" | "table" | "csv"; readonly command?: "pull" | "push"; readonly args?: ReadonlyArray; + /** + * Runs the real `projectRefLayer` (an unlinked workdir reaches its picker) and the real + * linked-project cache, so their `GET /v1/projects` and `GET /v1/projects/{ref}` calls go + * through `routes`. Linkage then comes from the workdir's `.temp/project-ref`, not `linked`. + */ + readonly realLayers?: boolean; } export function setupNotebooks(options: NotebooksSetupOptions) { @@ -267,6 +286,28 @@ export function setupNotebooks(options: NotebooksSetupOptions) { const telemetry = mockTelemetryStateTracked(); const cache = mockLinkedProjectCacheTracked(); + const cliSettings = + options.realLayers === true + ? mockCommandSettings({ workdir: options.workdir, projectId: Option.none() }) + : mockCommandSettings({ workdir: options.workdir }); + const projectRef = + options.realLayers === true + ? projectRefLayer.pipe( + Layer.provide( + Layer.succeed(CommandPlatformApiFactory, { + make: CommandPlatformApi.pipe(Effect.provide(http.layer)), + }), + ), + Layer.provide(cliSettings), + Layer.provide(mockTty({ stdinIsTty: true })), + Layer.provide(out.layer), + Layer.provide(BunServices.layer), + ) + : testProjectRefLayer(options.linked !== false); + const linkedProjectCache = + options.realLayers === true + ? realLinkedProjectCache({ httpClient: http.httpClientLayer, cliSettings }) + : cache.layer; const analytics = mockContextualAnalytics(); const process = mockProcessControl(); const command = options.command ?? "pull"; @@ -280,10 +321,10 @@ export function setupNotebooks(options: NotebooksSetupOptions) { layer: Layer.mergeAll( out.layer, http.layer, - mockCommandSettings({ workdir: options.workdir }), - testProjectRefLayer(options.linked !== false), + cliSettings, + projectRef, telemetry.layer, - cache.layer, + linkedProjectCache, analytics.layer, process.layer, commandRuntimeLayer(["notebooks", command]).pipe(Layer.provide(BunServices.layer)), diff --git a/apps/cli/tests/helpers/permission-pending-commands.ts b/apps/cli/tests/helpers/permission-pending-commands.ts deleted file mode 100644 index 6fc177ae04..0000000000 --- a/apps/cli/tests/helpers/permission-pending-commands.ts +++ /dev/null @@ -1,148 +0,0 @@ -/** Leaf command paths that don't declare their permissions with `withPermissions` yet. */ -export const PENDING_COMMANDS: ReadonlyArray = [ - "backups list", - "backups restore", - - "bootstrap", - - "branches create", - "branches delete", - "branches disable", - "branches get", - "branches list", - "branches pause", - "branches unpause", - "branches update", - - "completion bash", - "completion fish", - "completion powershell", - "completion zsh", - - "compute delete", - "compute list", - "compute logs", - "compute new", - "compute push", - "compute status", - - "config diff", - "config pull", - "config push", - - "db branch create", - "db branch delete", - "db branch list", - "db branch switch", - "db remote changes", - "db remote commit", - - "domains activate", - "domains create", - "domains delete", - "domains get", - "domains reverify", - - "encryption get-root-key", - "encryption update-root-key", - - "feedback add", - "feedback delete", - - "functions delete", - "functions deploy", - "functions download", - "functions list", - "functions new", - "functions serve", - - "gen bearer-jwt", - "gen keys", - "gen signing-key", - "gen types", - - "init", - - "issue bug", - "issue docs", - "issue feature", - - "link", - - "login", - - "logout", - - "network-bans get", - "network-bans remove", - - "network-restrictions get", - "network-restrictions update", - - "notebooks pull", - "notebooks push", - - "orgs create", - "orgs list", - - "postgres-config delete", - "postgres-config get", - "postgres-config update", - - "projects api-keys", - "projects create", - "projects delete", - "projects list", - - "pull", - - "secrets set", - "secrets unset", - - "services", - - "snippets download", - - "ssl-enforcement get", - "ssl-enforcement update", - - "sso add", - "sso info", - "sso list", - "sso remove", - "sso show", - "sso update", - - "stack destroy", - "stack list", - "stack logs", - "stack prepare", - "stack restart", - "stack start", - "stack status", - "stack stop", - - "start", - - "status", - - "stop", - - "storage cp", - "storage ls", - "storage mv", - "storage rm", - - "telemetry disable", - "telemetry enable", - "telemetry status", - - "unlink", - - "vanity-subdomains activate", - "vanity-subdomains check-availability", - "vanity-subdomains delete", - "vanity-subdomains get", - - "whoami", -]; diff --git a/apps/cli/tests/helpers/permission-real-layers.ts b/apps/cli/tests/helpers/permission-real-layers.ts index ad6abc4ef8..8d7255f6ea 100644 --- a/apps/cli/tests/helpers/permission-real-layers.ts +++ b/apps/cli/tests/helpers/permission-real-layers.ts @@ -179,6 +179,24 @@ export const provideEnv = (env: Record) => ConfigProvider.fromEnvRecord(env, { preserveEmptyStrings: true }), ); +/** A project as `GET /v1/projects/{ref}` returns it, with every field the typed client's decoder requires. */ +export const projectResponse = (ref: string) => ({ + id: ref, + ref, + name: "alpha", + organization_id: "org-123", + organization_slug: "acme", + region: "us-east-1", + created_at: "2026-05-27T01:02:03Z", + status: "ACTIVE_HEALTHY", + database: { + host: "db.alpha.supabase.co", + version: "15.1", + postgres_engine: "15", + release_channel: "ga", + }, +}); + /** * An `HttpClient` that records each request and answers the project lookup for `ref`, which is * what the real linked-project cache fetches. @@ -194,12 +212,7 @@ export function recordingProjectHttpClient(ref: string) { HttpClientResponse.fromWeb( request, found - ? Response.json({ - ref, - name: "alpha", - organization_id: "org-123", - organization_slug: "acme", - }) + ? Response.json(projectResponse(ref)) : Response.json({ message: "unexpected request" }, { status: 404 }), ), ); diff --git a/apps/cli/tests/helpers/permission-registry.unit.test.ts b/apps/cli/tests/helpers/permission-registry.unit.test.ts index e5b7e5c967..cba43c6be1 100644 --- a/apps/cli/tests/helpers/permission-registry.unit.test.ts +++ b/apps/cli/tests/helpers/permission-registry.unit.test.ts @@ -154,11 +154,13 @@ describe("applicableOperations", () => { }); describe("declaredPermissions", () => { - it("tells a path missing from the tree apart from a command with no declaration", () => { + it("finds a declared leaf only in the tree that includes it, and reports an absent path as not found", () => { expect(declaredPermissions("not-a-real-command")).toEqual({ _tag: "NotFound" }); - expect(declaredPermissions("compute list")).toEqual({ _tag: "NotFound" }); - expect(declaredPermissions("compute list", { computeEnabled: true })).toEqual({ - _tag: "Undeclared", - }); + expect(declaredPermissions("stack list")).toEqual({ _tag: "NotFound" }); + expect(declaredPermissions("stack list", { stackBackend: "stack" })._tag).toBe("Declared"); + }); + + it("reports a command group, which carries no declaration, as undeclared", () => { + expect(declaredPermissions("db branch")).toEqual({ _tag: "Undeclared" }); }); }); diff --git a/apps/cli/tests/helpers/storage.ts b/apps/cli/tests/helpers/storage.ts index 716f30b25d..4bb0a38c86 100644 --- a/apps/cli/tests/helpers/storage.ts +++ b/apps/cli/tests/helpers/storage.ts @@ -2,7 +2,7 @@ import { mkdirSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; import { BunServices } from "@effect/platform-bun"; -import { Effect, Layer, Option, Redacted, Stream } from "effect"; +import { ConfigProvider, Effect, Layer, Option, Redacted, Stream } from "effect"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; import type { @@ -34,9 +34,11 @@ import { transportFailure, mockCommandSettings, mockLinkedProjectCacheTracked, + mockCommandPlatformApi, mockCommandPlatformApiService, mockTelemetryStateTracked, } from "./command-mocks.ts"; +import { projectResponse, realLinkedProjectCache } from "./permission-real-layers.ts"; interface StorageRoute { readonly method: string; @@ -80,6 +82,8 @@ export interface SetupStorageOptions { readonly explicitWorkdir?: boolean; readonly stackBackend?: boolean; readonly stackApi?: SetupStorageStackApiOptions; + /** Records the Management API requests through the real factory and runs the real linked-project cache. */ + readonly recordManagementApi?: boolean; } export interface SetupStorageStackApiOptions { @@ -418,20 +422,43 @@ export function setupStorage(workdir: string, opts: SetupStorageOptions) { }, }); + const recorded = + opts.recordManagementApi === true + ? mockCommandPlatformApi({ + handler: (request) => + Effect.succeed( + request.method === "GET" && request.url.endsWith(`/v1/projects/${projectRefRef}`) + ? jsonResponse(request, 200, projectResponse(projectRefRef)) + : request.method === "GET" && + request.url.endsWith(`/v1/projects/${projectRefRef}/api-keys`) + ? jsonResponse(request, 200, opts.apiKeys ?? defaultApiKeys) + : jsonResponse(request, 404, { message: "no mock route" }), + ), + }) + : undefined; + const stackApi = buildStorageStackApi(workdir, opts.stackApi); + const cliSettings = mockCommandSettings({ + workdir, + explicitWorkdir: opts.explicitWorkdir ?? false, + }); const layer = Layer.mergeAll( out.layer, httpLayer, telemetry.layer, - linkedCache.layer, - mockCommandSettings({ workdir, explicitWorkdir: opts.explicitWorkdir ?? false }), + recorded === undefined + ? linkedCache.layer + : realLinkedProjectCache({ httpClient: recorded.httpClientLayer, cliSettings }), + cliSettings, BunServices.layer, cliConfigValuesTestLayer, projectRefLayer, - Layer.succeed(CommandPlatformApiFactory, { - make: CommandPlatformApi.pipe(Effect.provide(managementApi.layer)), - }), + recorded === undefined + ? Layer.succeed(CommandPlatformApiFactory, { + make: CommandPlatformApi.pipe(Effect.provide(managementApi.layer)), + }) + : recorded.factoryLayer, Layer.succeed(YesFlag, opts.yes ?? false), // `storage rm` confirms deletions via `promptYesNo`, answered by `confirm`; // other storage commands ignore it. @@ -448,7 +475,22 @@ export function setupStorage(workdir: string, opts: SetupStorageOptions) { unusedStackServices, stackApi.layer, ...(opts.stackBackend === true ? [stackBackendLayer("stack")] : []), + ...(recorded === undefined + ? [] + : [ + ConfigProvider.layer( + ConfigProvider.fromEnvRecord(process.env, { preserveEmptyStrings: true }), + ), + ]), ); - return { layer, out, requests, telemetry, linkedCache, stackCalls: stackApi.stackCalls }; + return { + layer, + out, + requests, + telemetry, + linkedCache, + stackCalls: stackApi.stackCalls, + managementRequests: recorded?.requests ?? [], + }; } diff --git a/apps/cli/tests/lint/no-raw-management-api-url.unit.test.ts b/apps/cli/tests/lint/no-raw-management-api-url.unit.test.ts new file mode 100644 index 0000000000..62d0f0a0f0 --- /dev/null +++ b/apps/cli/tests/lint/no-raw-management-api-url.unit.test.ts @@ -0,0 +1,99 @@ +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { basename, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; + +const repoRoot = fileURLToPath(new URL("../../../../", import.meta.url)); +const oxlintBin = join(repoRoot, "node_modules/.bin/oxlint"); +const pluginPath = join(repoRoot, "tools/oxlint/supabase-plugin.js"); + +const RULE_NAME = "no-raw-management-api-url"; +const RULE = `supabase/${RULE_NAME}`; + +const flagged = { + "template-base.ts": "export const url = `${apiUrl}/v1/projects`;", + "template-nested-path.ts": "export const url = `${apiUrl}/v2/projects/${ref}/config`;", + "template-platform.ts": "export const url = `${apiUrl}/platform/cli/login/${id}`;", + "template-absolute.ts": "export const url = `https://api.supabase.com/v1/projects/${ref}`;", + "concatenation.ts": 'export const url = apiUrl + "/v1/projects";', + "url-constructor.ts": 'export const url = new URL("/v1/projects", apiUrl);', + "http-client-request.ts": + 'export const req = HttpClientRequest.get("https://api.supabase.com/v1/projects");', + "fetch-call.ts": 'export const res = fetch("/v2/projects");', + "client-method-literal.ts": 'export const res = client.get("/v1/profile");', + "http-client-request-relative.ts": 'export const req = HttpClientRequest.get("/v1/projects");', + "constant-path-concatenation.ts": [ + 'const PROFILE_PATH = "/v1/profile";', + "export const url = apiUrl + PROFILE_PATH;", + ].join("\n"), + "constant-path-template.ts": [ + 'const PROFILE_PATH = "/v1/profile";', + "export const url = `${apiUrl}${PROFILE_PATH}`;", + ].join("\n"), +}; + +const allowed = { + "service-rest.ts": "export const url = `${baseUrl}/rest/v1/`;", + "service-auth.ts": "export const url = `${baseUrl}/auth/v1/health`;", + "service-storage.ts": 'export const req = HttpClientRequest.get("/storage/v1/bucket");', + "service-functions.ts": "export const url = `http://127.0.0.1:${port}/functions/v1/${name}`;", + "studio-platform.ts": "export const url = `${studioUrl}/api/platform/profile`;", + "otlp-traces-path.ts": 'export const OTLP_TRACES_PATH = "/v1/traces";', + "unrelated-literal.ts": 'export const res = client.get("/health");', + "version-lookalike.ts": 'export const label = "/v10/projects";', + "documented-exception.ts": [ + `// oxlint-disable-next-line ${RULE} -- not in the OpenAPI spec`, + "export const url = `${apiUrl}/platform/cli/login/${id}`;", + ].join("\n"), +}; + +describe("no-raw-management-api-url", () => { + let dir: string; + let flaggedFiles: Set; + let lintedFiles: Set; + + beforeAll(() => { + dir = mkdtempSync(join(tmpdir(), "supabase-lint-rule-")); + mkdirSync(join(dir, "src")); + writeFileSync( + join(dir, ".oxlintrc.json"), + JSON.stringify({ + jsPlugins: [pluginPath], + categories: { correctness: "off" }, + rules: { [RULE]: "error" }, + }), + ); + for (const [name, source] of Object.entries({ ...flagged, ...allowed })) { + writeFileSync(join(dir, "src", name), `${source}\n`); + } + + const result = spawnSync( + oxlintBin, + ["--config", join(dir, ".oxlintrc.json"), "--format", "json", join(dir, "src")], + { encoding: "utf8" }, + ); + const report = JSON.parse(result.stdout) as { + diagnostics: { filename: string; code: string }[]; + number_of_files: number; + }; + flaggedFiles = new Set( + report.diagnostics.filter((d) => d.code.includes(RULE_NAME)).map((d) => basename(d.filename)), + ); + lintedFiles = new Set(Object.keys({ ...flagged, ...allowed })); + expect(report.number_of_files).toBe(lintedFiles.size); + }); + + afterAll(() => { + rmSync(dir, { recursive: true, force: true }); + }); + + test.each(Object.keys(flagged))("flags %s", (name) => { + expect(flaggedFiles.has(name)).toBe(true); + }); + + test.each(Object.keys(allowed))("allows %s", (name) => { + expect(flaggedFiles.has(name)).toBe(false); + }); +}); diff --git a/docs/adr/0026-slim-artifact-mirrors.md b/docs/adr/0026-slim-artifact-mirrors.md index 2e9aab7c74..bed767bf6b 100644 --- a/docs/adr/0026-slim-artifact-mirrors.md +++ b/docs/adr/0026-slim-artifact-mirrors.md @@ -139,6 +139,10 @@ pins it. A hotfix PR opened before the upgrade appeared is left as is. Merge it upgrade and the upgrade PR conflicts until the next run for that service rewrites it; merge the upgrade first and the hotfix PR is superseded and must be closed by hand. +A service listed in the planner's `UPGRADE_HOLDS` never gets an upgrade: the planner warns with +the hold's reason instead and still plans hotfixes for the pinned upstream. Removing the entry +resumes upgrades; the manual `--release` invocation is unaffected. + A service with a single pin and no engine variants has a single line, which accepts any comparable newer upstream: a Studio year rollover or a postgrest major bump moves that line forward. Only postgres, which has additional pins and engine variants, assigns each release tag to a line, even diff --git a/knip.jsonc b/knip.jsonc index 71761b117e..78fe014d65 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -8,6 +8,11 @@ // bunx in apps/cli/tests/helpers/npm-registry.ts, which knip cannot trace. "ignoreDependencies": ["verdaccio"], "workspaces": { + ".": { + // flaky-check.yml runs it from a second checkout of the workflow revision, through a + // path knip cannot trace. + "entry": [".github/scripts/flaky-check/cli.ts"], + }, "apps/cli": { "entry": [ // Bundled by `bun build` in the build:shim script; knip's bun resolver derives no diff --git a/packages/api/scripts/download-openapi.ts b/packages/api/scripts/download-openapi.ts index fd368bc06d..48edc093fd 100644 --- a/packages/api/scripts/download-openapi.ts +++ b/packages/api/scripts/download-openapi.ts @@ -26,7 +26,7 @@ type OpenApiSource = { readonly baseUrl: string; }; -type JsonPatchOperation = +type JsonPatchOperation = ( | { readonly op: "add" | "test" | "replace"; readonly path: string; @@ -35,7 +35,10 @@ type JsonPatchOperation = | { readonly op: "remove"; readonly path: string; - }; + } +) & { + readonly $comment?: unknown; +}; function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null; @@ -55,6 +58,10 @@ function jsonPointerSegments(pointer: string): ReadonlyArray { return pointer.slice(1).split("/").map(unescapeJsonPointerSegment); } +function pointerFromSegments(segments: ReadonlyArray): string { + return segments.map((segment) => `/${segment.replace(/~/g, "~0").replace(/\//g, "~1")}`).join(""); +} + function getJsonPointerValue(document: unknown, pointer: string): unknown { let current = document; for (const segment of jsonPointerSegments(pointer)) { @@ -79,10 +86,7 @@ function replaceJsonPointerValue(document: unknown, pointer: string, value: unkn throw new Error("Replacing the document root is not supported."); } - let parent = document; - for (const segment of segments.slice(0, -1)) { - parent = getJsonPointerValue(parent, `/${segment.replace(/~/g, "~0").replace(/\//g, "~1")}`); - } + const parent = getJsonPointerValue(document, pointerFromSegments(segments.slice(0, -1))); const key = segments[segments.length - 1]!; if (Array.isArray(parent)) { @@ -106,10 +110,7 @@ function addJsonPointerValue(document: unknown, pointer: string, value: unknown) throw new Error("Adding the document root is not supported."); } - let parent = document; - for (const segment of segments.slice(0, -1)) { - parent = getJsonPointerValue(parent, `/${segment.replace(/~/g, "~0").replace(/\//g, "~1")}`); - } + const parent = getJsonPointerValue(document, pointerFromSegments(segments.slice(0, -1))); const key = segments[segments.length - 1]!; if (Array.isArray(parent)) { @@ -127,44 +128,27 @@ function addJsonPointerValue(document: unknown, pointer: string, value: unknown) parent[key] = value; } -function removeJsonPointerValue(document: unknown, pointer: string): boolean { +function removeJsonPointerValue(document: unknown, pointer: string): void { const segments = jsonPointerSegments(pointer); if (segments.length === 0) { - return false; - } - - let parent: unknown = document; - for (const segment of segments.slice(0, -1)) { - if (Array.isArray(parent)) { - const index = Number(segment); - if (!Number.isInteger(index) || index < 0 || index >= parent.length) { - return false; - } - parent = parent[index]; - continue; - } - if (isRecord(parent) && segment in parent) { - parent = parent[segment]; - continue; - } - return false; + throw new Error("Removing the document root is not supported."); } + const parent = getJsonPointerValue(document, pointerFromSegments(segments.slice(0, -1))); const key = segments[segments.length - 1]!; if (Array.isArray(parent)) { const index = Number(key); if (!Number.isInteger(index) || index < 0 || index >= parent.length) { - return false; + throw new Error(`JSON pointer ${JSON.stringify(pointer)} does not exist.`); } parent.splice(index, 1); - return true; + return; } if (!isRecord(parent) || !(key in parent)) { - return false; + throw new Error(`JSON pointer ${JSON.stringify(pointer)} does not exist.`); } delete parent[key]; - return true; } function assertJsonPatchOperation(value: unknown): asserts value is JsonPatchOperation { @@ -194,7 +178,22 @@ function assertJsonPatchOperation(value: unknown): asserts value is JsonPatchOpe } function valuesEqual(left: unknown, right: unknown): boolean { - return JSON.stringify(left) === JSON.stringify(right); + if (Array.isArray(left) || Array.isArray(right)) { + return ( + Array.isArray(left) && + Array.isArray(right) && + left.length === right.length && + left.every((entry, index) => valuesEqual(entry, right[index])) + ); + } + if (isRecord(left) && isRecord(right)) { + const leftKeys = Object.keys(left); + return ( + leftKeys.length === Object.keys(right).length && + leftKeys.every((key) => key in right && valuesEqual(left[key], right[key])) + ); + } + return left === right; } export function applyOpenApiOverrides( @@ -217,24 +216,59 @@ export function applyOpenApiOverrides( continue; } if (override.op === "remove") { - // Silently ignores removing a pointer that doesn't exist (a deviation from - // RFC 6902): staging's /api/v2-json is served by two backend variants that - // disagree about whether the webhook paths exist, so a strict remove would - // fail on most staging runs. removeJsonPointerValue(document, override.path); continue; } + if (valuesEqual(getJsonPointerValue(document, override.path), override.value)) { + throw new Error( + `OpenAPI override replace at ${override.path} is redundant: upstream already has ${JSON.stringify(override.value)}.`, + ); + } replaceJsonPointerValue(document, override.path, override.value); } return document; } -async function loadOpenApiOverrides(): Promise> { - const parsed = JSON.parse(await readFile(OPENAPI_OVERRIDES_PATH, "utf8")); +function hasComment(override: JsonPatchOperation): boolean { + return typeof override.$comment === "string" && override.$comment.trim().length > 0; +} + +/** + * Validates the override list's structure: every `replace` is guarded by a `test` on the same + * path directly before it, and every test+replace pair or standalone entry explains its upstream + * cause in a `$comment`. + */ +export function parseOpenApiOverrides(parsed: unknown): ReadonlyArray { if (!Array.isArray(parsed)) { throw new Error("OpenAPI overrides file must contain a JSON Patch array."); } - return parsed; + const entries: Array = []; + for (const override of parsed) { + assertJsonPatchOperation(override); + entries.push(override); + } + + for (let index = 0; index < entries.length; index++) { + const entry = entries[index]!; + const next = entries[index + 1]; + const isGuardedPair = entry.op === "test" && next?.op === "replace" && next.path === entry.path; + if (entry.op === "replace") { + throw new Error( + `OpenAPI override replace at ${entry.path} must be directly preceded by a test on the same path.`, + ); + } + if (!hasComment(entry) && !(isGuardedPair && hasComment(next))) { + throw new Error(`OpenAPI override ${entry.op} at ${entry.path} needs a $comment.`); + } + if (isGuardedPair) { + index++; + } + } + return entries; +} + +export async function loadOpenApiOverrides(): Promise> { + return parseOpenApiOverrides(JSON.parse(await readFile(OPENAPI_OVERRIDES_PATH, "utf8"))); } function assertOpenApiSource(value: unknown): asserts value is OpenApiSource { @@ -398,9 +432,6 @@ export function mergeOpenApiDocuments( }; } -// Must run after overrides are applied: some upstream webhook operations share -// duplicate operationIds that the overrides remove, so validating first would -// abort regeneration. export function assertMergedOpenApiDocument(document: OpenApiDocument): void { const operationClaims = new Map>(); diff --git a/packages/api/scripts/download-openapi.unit.test.ts b/packages/api/scripts/download-openapi.unit.test.ts index a7521eb899..6f600f29bb 100644 --- a/packages/api/scripts/download-openapi.unit.test.ts +++ b/packages/api/scripts/download-openapi.unit.test.ts @@ -4,7 +4,9 @@ import { applyOpenApiOverrides, assertMergedOpenApiDocument, assertOpenApiDocument, + loadOpenApiOverrides, mergeOpenApiDocuments, + parseOpenApiOverrides, resolveOpenApiBaseUrl, resolveOpenApiSpecUrl, resolveOpenApiSpecUrls, @@ -322,58 +324,94 @@ describe("download-openapi", () => { warnSpy.mockRestore(); }); - test("applyOpenApiOverrides tolerantly removes JSON pointers that no longer exist", () => { + test("applyOpenApiOverrides removes existing JSON pointers", () => { const withExistingPath = { paths: { "/v1/foo": { get: {} } } }; applyOpenApiOverrides(withExistingPath, [{ op: "remove", path: "/paths/~1v1~1foo" }]); expect(withExistingPath.paths).toEqual({}); - const withMissingPath = { paths: {} }; - applyOpenApiOverrides(withMissingPath, [{ op: "remove", path: "/paths/~1v1~1missing" }]); - expect(withMissingPath.paths).toEqual({}); - - const withMissingIntermediateSegment = { paths: {} }; - applyOpenApiOverrides(withMissingIntermediateSegment, [ - { op: "remove", path: "/paths/~1nope/get" }, - ]); - expect(withMissingIntermediateSegment.paths).toEqual({}); - const withArray = { paths: {}, components: { schemas: { Foo: { enum: ["a", "b", "c"] } } } }; applyOpenApiOverrides(withArray, [{ op: "remove", path: "/components/schemas/Foo/enum/1" }]); expect(withArray.components.schemas.Foo.enum).toEqual(["a", "c"]); }); + test("fails when a remove override targets a missing pointer", () => { + expect(() => + applyOpenApiOverrides({ paths: {} }, [{ op: "remove", path: "/paths/~1v1~1missing" }]), + ).toThrow('JSON pointer "/paths/~1v1~1missing" does not exist.'); + expect(() => + applyOpenApiOverrides({ paths: {} }, [{ op: "remove", path: "/paths/~1nope/get" }]), + ).toThrow('JSON pointer "/paths/~1nope" does not exist.'); + }); + + test("fails when a replace override already matches upstream", () => { + expect(() => + applyOpenApiOverrides( + { paths: {}, components: { schemas: { Body: { required: ["a", "b"] } } } }, + [ + { op: "test", path: "/components/schemas/Body/required", value: ["a", "b"] }, + { op: "replace", path: "/components/schemas/Body/required", value: ["a", "b"] }, + ], + ), + ).toThrow("is redundant"); + }); + test("rejects a remove override that carries a value", () => { expect(() => applyOpenApiOverrides({ paths: {} }, [{ op: "remove", path: "/paths", value: {} }]), ).toThrow("OpenAPI remove overrides must not include a value."); }); - test("assertMergedOpenApiDocument passes after the webhook-collision remove overrides are applied but fails without them", () => { - const buildDocument = () => ({ - paths: { - "/v2/projects/{ref}/webhooks/endpoints": { - get: { operationId: "allV2ProjectsByRefWebhooks" }, - }, - "/v2/projects/{ref}/webhooks/endpoints/{id}": { - get: { operationId: "allV2ProjectsByRefWebhooks" }, - }, - "/v2/projects/{ref}": { - get: { operationId: "v2-get-a-project" }, - }, - }, + describe("override file structure", () => { + const test_ = (path: string, value: unknown = []) => ({ op: "test", path, value }); + const replace = (path: string, value: unknown = ["x"]) => ({ op: "replace", path, value }); + + test("accepts the committed overrides file", async () => { + const overrides = await loadOpenApiOverrides(); + + expect(overrides.length).toBeGreaterThan(0); }); - const overrides = [ - { op: "remove", path: "/paths/~1v2~1projects~1{ref}~1webhooks~1endpoints" }, - { op: "remove", path: "/paths/~1v2~1projects~1{ref}~1webhooks~1endpoints~1{id}" }, - ]; + test("rejects a replace that is not directly preceded by a test on the same path", () => { + expect(() => + parseOpenApiOverrides([{ ...replace("/a"), $comment: "Upstream is wrong." }]), + ).toThrow("must be directly preceded by a test on the same path"); + expect(() => + parseOpenApiOverrides([ + { ...test_("/a"), $comment: "Upstream is wrong." }, + { op: "remove", path: "/c", $comment: "Upstream is wrong." }, + replace("/a"), + ]), + ).toThrow("must be directly preceded by a test on the same path"); + expect(() => + parseOpenApiOverrides([{ ...test_("/a"), $comment: "Upstream is wrong." }, replace("/b")]), + ).toThrow("must be directly preceded by a test on the same path"); + }); - expect(() => assertMergedOpenApiDocument(buildDocument())).toThrow( - 'Duplicate OpenAPI operationId "allV2ProjectsByRefWebhooks"', - ); + test("requires a $comment on every pair and standalone entry", () => { + expect(() => parseOpenApiOverrides([test_("/a"), replace("/a")])).toThrow( + "test at /a needs a $comment", + ); + expect(() => parseOpenApiOverrides([{ op: "remove", path: "/a" }])).toThrow( + "remove at /a needs a $comment", + ); + expect(() => + parseOpenApiOverrides([{ op: "add", path: "/a", value: 1, $comment: " " }]), + ).toThrow("add at /a needs a $comment"); + }); - const patchedDocument = applyOpenApiOverrides(buildDocument(), overrides); - expect(Object.keys(patchedDocument.paths)).toEqual(["/v2/projects/{ref}"]); - expect(() => assertMergedOpenApiDocument(patchedDocument)).not.toThrow(); + test("accepts a commented test and replace pair on either entry", () => { + expect( + parseOpenApiOverrides([ + test_("/a"), + { ...replace("/a"), $comment: "Upstream is wrong." }, + { ...test_("/b"), $comment: "Upstream is wrong." }, + replace("/b"), + ]), + ).toHaveLength(4); + }); + + test("rejects a file that is not a JSON Patch array", () => { + expect(() => parseOpenApiOverrides({})).toThrow("must contain a JSON Patch array"); + }); }); }); diff --git a/packages/api/scripts/generate.ts b/packages/api/scripts/generate.ts index 252dbf6305..4e5f22f03f 100644 --- a/packages/api/scripts/generate.ts +++ b/packages/api/scripts/generate.ts @@ -246,13 +246,14 @@ export function sanitizeOpenApiSchema( continue; } - if ( - !inPropertiesMap && - key === "propertyNames" && - isRecord(rawValue) && - Object.keys(rawValue).length === 1 && - rawValue.type === "string" - ) { + // The OpenAPI 3.0 converter rejects `propertyNames`; key constraints are left to the server. + if (!inPropertiesMap && key === "propertyNames" && isRecord(rawValue)) { + continue; + } + + // The OpenAPI 3.0 converter rejects `const`; a one-value `enum` is equivalent. + if (!inPropertiesMap && key === "const") { + Object.assign(sanitized, { enum: [rawValue] }); continue; } @@ -310,15 +311,135 @@ export function sanitizeOpenApiSchema( return sanitized; } -function convertSchemaToDraft2020(schema: OpenApiSchema) { +function convertSchemaToDraft2020( + schema: OpenApiSchema, + options?: { readonly openEnums: boolean }, +) { const sanitized = sanitizeOpenApiSchema(schema); + const prepared = options?.openEnums === true ? openStringEnums(sanitized) : sanitized; return ( - sanitized.$schema === JsonSchema.META_SCHEMA_URI_DRAFT_2020_12 - ? JsonSchema.fromSchemaDraft2020_12(sanitized) - : JsonSchema.fromSchemaOpenApi3_0(sanitized) + prepared.$schema === JsonSchema.META_SCHEMA_URI_DRAFT_2020_12 + ? JsonSchema.fromSchemaDraft2020_12(prepared) + : JsonSchema.fromSchemaOpenApi3_0(prepared) ).schema; } +const OPEN_STRING_FORMAT = "x-open-string"; +const COMPONENT_REF_PREFIX = "#/components/schemas/"; + +function isOpenableStringEnum(schema: OpenApiSchema): boolean { + if (!Array.isArray(schema.enum) || (schema.type !== undefined && schema.type !== "string")) { + return false; + } + const values = schema.enum.filter((value) => value !== null); + return values.length > 1 && values.every((value) => typeof value === "string"); +} + +/** + * Widens every multi-value string enum to `enum | any string`. Single-value enums stay closed + * because they tag union variants, and widening them would make variant matching ambiguous. + */ +export function openStringEnums(schema: OpenApiSchema): OpenApiSchema { + const visitSchema = (value: OpenApiSchema): OpenApiSchema => { + const visited: OpenApiSchema = {}; + for (const [key, entry] of Object.entries(value)) { + visited[key] = visit(entry); + } + return isOpenableStringEnum(visited) + ? { anyOf: [visited, { type: "string", format: OPEN_STRING_FORMAT }] } + : visited; + }; + const visit = (value: unknown): unknown => { + if (Array.isArray(value)) { + return value.map(visit); + } + if (!isRecord(value)) { + return value; + } + return visitSchema(value); + }; + return visitSchema(schema); +} + +function collectComponentRefs(value: unknown, into: Set): void { + if (Array.isArray(value)) { + for (const entry of value) { + collectComponentRefs(entry, into); + } + } else if (isRecord(value)) { + for (const [key, entry] of Object.entries(value)) { + if (key === "$ref" && typeof entry === "string" && entry.startsWith(COMPONENT_REF_PREFIX)) { + into.add(entry.slice(COMPONENT_REF_PREFIX.length)); + } else { + collectComponentRefs(entry, into); + } + } + } +} + +function componentRefClosure( + components: Record, + roots: ReadonlyArray, +): ReadonlySet { + const pending = new Set(); + for (const root of roots) { + collectComponentRefs(root, pending); + } + const reachable = new Set(); + for (const name of pending) { + if (reachable.has(name)) { + continue; + } + reachable.add(name); + const component = components[name]; + if (component !== undefined) { + collectComponentRefs(component, pending); + } + } + return reachable; +} + +function containsOpenableStringEnum(value: unknown): boolean { + if (Array.isArray(value)) { + return value.some(containsOpenableStringEnum); + } + return ( + isRecord(value) && + (isOpenableStringEnum(value) || Object.values(value).some(containsOpenableStringEnum)) + ); +} + +/** + * Components reachable from response schemas whose string enums are widened. A component that + * both requests and responses reach would need two variants, which the generator does not model. + */ +function outputEnumComponents( + components: Record, + operations: ReadonlyArray, +): ReadonlySet { + const outputRoots = operations.flatMap((operation) => + operation.response.kind !== "void" && operation.outputSchema ? [operation.outputSchema] : [], + ); + const outputReachable = componentRefClosure(components, outputRoots); + const inputReachable = componentRefClosure( + components, + operations.map((operation) => operation.inputSchema), + ); + for (const name of outputReachable) { + const component = components[name]; + if ( + inputReachable.has(name) && + component !== undefined && + containsOpenableStringEnum(sanitizeOpenApiSchema(component)) + ) { + throw new Error( + `Schema "${name}" has string enums and is reachable from both request and response schemas; response enums are widened, so it cannot be shared.`, + ); + } + } + return outputReachable; +} + function containsBinarySchema(schema: OpenApiSchema): boolean { if (schema.$ref !== undefined) { return false; @@ -351,6 +472,16 @@ function replaceBinarySchemaCode(code: string): string { return code.replace(/Schema\.String\.annotate\(\{\s*"format":\s*"binary"\s*\}\)/g, "BinaryInput"); } +function replaceOpenStringCode(code: string): string { + return code.replace( + new RegExp( + `Schema\\.String\\.annotate\\(\\{\\s*"format":\\s*"${OPEN_STRING_FORMAT}"\\s*\\}\\)`, + "g", + ), + "OpenString", + ); +} + function normalizeJsonSchemaValue(value: unknown): unknown { if (Array.isArray(value)) { return value.map(normalizeJsonSchemaValue); @@ -755,10 +886,15 @@ function renderSchemaSource( operations: ReadonlyArray, ): string { const schemaEntries = operations.flatMap((operation) => { - const entries: Array<{ readonly name: string; readonly schema: OpenApiSchema }> = [ + const entries: Array<{ + readonly name: string; + readonly schema: OpenApiSchema; + readonly isOutput: boolean; + }> = [ { name: operation.inputSchemaName, schema: operation.inputSchema, + isOutput: false, }, ]; @@ -766,22 +902,29 @@ function renderSchemaSource( entries.push({ name: operation.outputSchemaName, schema: operation.outputSchema, + isOutput: true, }); } return entries; }); + const components = document.components?.schemas ?? {}; + const openEnumComponents = outputEnumComponents(components, operations); const definitions = Object.fromEntries( - Object.entries(document.components?.schemas ?? {}).map(([name, schema]) => [ + Object.entries(components).map(([name, schema]) => [ name, - normalizeNullableJsonSchema(convertSchemaToDraft2020(schema)), + normalizeNullableJsonSchema( + convertSchemaToDraft2020(schema, { openEnums: openEnumComponents.has(name) }), + ), ]), ); const nameMap = schemaEntries.map((entry) => entry.name); const schemas = schemaEntries.map((entry) => - normalizeNullableJsonSchema(convertSchemaToDraft2020(entry.schema)), + normalizeNullableJsonSchema( + convertSchemaToDraft2020(entry.schema, { openEnums: entry.isOutput }), + ), ); if (!Arr.isArrayNonEmpty(schemas)) { @@ -818,15 +961,16 @@ function renderSchemaSource( containsBinarySchema(operation.inputSchema), ); + const finalizeCode = (code: string): string => { + const withOpenStrings = replaceOpenStringCode(code); + return hasBinaryInputs ? replaceBinarySchemaCode(withOpenStrings) : withOpenStrings; + }; + const parts: Array = []; if (codeDocument.references.nonRecursives.length > 0) { parts.push("// non-recursive definitions"); for (const reference of codeDocument.references.nonRecursives) { - parts.push( - `export const ${reference.$ref} = ${ - hasBinaryInputs ? replaceBinarySchemaCode(reference.code.runtime) : reference.code.runtime - }`, - ); + parts.push(`export const ${reference.$ref} = ${finalizeCode(reference.code.runtime)}`); } } @@ -835,7 +979,7 @@ function renderSchemaSource( parts.push("// recursive definitions"); for (const [name, code] of recursiveEntries) { parts.push( - `export type ${name} = ${code.Type}\nexport const ${name} = ${hasBinaryInputs ? replaceBinarySchemaCode(code.runtime) : code.runtime}`, + `export type ${name} = ${code.Type}\nexport const ${name} = ${finalizeCode(code.runtime)}`, ); } } @@ -851,9 +995,7 @@ function renderSchemaSource( parts.push("// operation schemas"); for (const [index, code] of codeDocument.codes.entries()) { const name = nameMap[index]!; - parts.push( - `export const ${name} = ${hasBinaryInputs ? replaceBinarySchemaCode(code.runtime) : code.runtime}`, - ); + parts.push(`export const ${name} = ${finalizeCode(code.runtime)}`); } } @@ -863,6 +1005,14 @@ function renderSchemaSource( } } + // `string & {}` keeps the literal members of a widened enum visible to autocomplete. + if (parts.some((part) => /\bOpenString\b/u.test(part))) { + parts.unshift( + "// open string helper", + 'export const OpenString = Schema.declare((value: unknown): value is string & {} => typeof value === "string")', + ); + } + return parts.join("\n") + "\n"; } @@ -960,19 +1110,21 @@ export function renderEffectClient(operations: ReadonlyArray = {}" : `input: typeof operationDefinitions.${operation.operationName}.inputSchema.Type`; const inputExpression = isEmptyInput ? "{}" : "input"; return ` ${methodName}: ( - ${signature} + ${inputParameter}, + options?: SupabaseApiRequestOptions, ): Effect.Effect => Effect.gen(function* () { const client = yield* SupabaseApiClient; return yield* client.execute<${JSON.stringify(operation.operationName)}>( operationDefinitions.${operation.operationName}, ${inputExpression}, + options, ); }),`; }) @@ -984,29 +1136,9 @@ ${methods} }) .join("\n"); - const executorCases = operations - .map((operation) => { - const { version, methodName } = operation; - const isEmptyInput = - operation.inputSchema.type === "object" && - Object.keys(operation.inputSchema.properties ?? {}).length === 0; - const decodedExpression = `Schema.decodeUnknownEffect(operationDefinitions.${operation.operationName}.inputSchema)(input)`; - const decodedParam = isEmptyInput ? "_decoded" : "decoded"; - const callExpression = isEmptyInput - ? `api.${version}.${methodName}()` - : `api.${version}.${methodName}(decoded)`; - - return ` case ${JSON.stringify(operation.operationName)}: - return ${decodedExpression}.pipe( - Effect.flatMap((${decodedParam}) => ${callExpression}), - );`; - }) - .join("\n"); - - return `import { Effect, Schema } from "effect"; + return `import { Effect } from "effect"; -import type { EffectClient } from "../internal/effect-client.ts"; -import type { SupabaseApiError } from "../internal/client.ts"; +import type { SupabaseApiError, SupabaseApiRequestOptions } from "../internal/client.ts"; import { SupabaseApiClient } from "../internal/client.ts"; import { operationDefinitions } from "./contracts.ts"; @@ -1015,17 +1147,6 @@ ${versionedOperationsSource} } as const; export type GeneratedEffectOperations = typeof versionedEffectOperations; -type GeneratedApiClient = EffectClient; - -export function executeApiClientOperation( - operationId: keyof typeof operationDefinitions, - api: GeneratedApiClient, - input: unknown, -) { - switch (operationId) { -${executorCases} - } -} `; } diff --git a/packages/api/scripts/generate.unit.test.ts b/packages/api/scripts/generate.unit.test.ts index 433e4d10d7..9fb33bef1d 100644 --- a/packages/api/scripts/generate.unit.test.ts +++ b/packages/api/scripts/generate.unit.test.ts @@ -8,6 +8,7 @@ import { extractOperations, normalizeNullableJsonSchema, normalizeQueryParameterSchema, + openStringEnums, operationMethodName, operationVersionFromPath, renderContracts, @@ -132,6 +133,122 @@ describe("generate", () => { }); }); + test("rewrites const to a one-value enum without touching a property named const", () => { + expect( + sanitizeOpenApiSchema({ + type: "object", + properties: { const: { type: "string", const: "fixed" } }, + }), + ).toEqual({ + type: "object", + properties: { const: { type: "string", enum: ["fixed"] } }, + }); + }); + + test("widens multi-value string enums and keeps single-value discriminators closed", () => { + expect( + openStringEnums({ + type: "object", + properties: { + status: { type: "string", enum: ["A", "B"] }, + kind: { type: "string", enum: ["only"] }, + level: { type: "integer", enum: [1, 2] }, + }, + }), + ).toEqual({ + type: "object", + properties: { + status: { + anyOf: [ + { type: "string", enum: ["A", "B"] }, + { type: "string", format: "x-open-string" }, + ], + }, + kind: { type: "string", enum: ["only"] }, + level: { type: "integer", enum: [1, 2] }, + }, + }); + }); + + describe("open response enums", () => { + const statusEnum = { type: "string", enum: ["A", "B"] }; + + function enumFixture(options: { readonly sharedComponent: boolean }): OpenApiDocument { + const responseSchema = { + type: "object", + properties: { + status: statusEnum, + shared: { $ref: "#/components/schemas/Shared" }, + }, + required: ["status", "shared"], + }; + return { + openapi: "3.0.0", + info: { title: "Test API", version: "1.0.0" }, + components: { + schemas: { + Shared: { + type: "object", + properties: { mode: statusEnum }, + required: ["mode"], + }, + }, + }, + paths: { + "/v1/things": { + get: jsonResponseOperation("v1-get-thing", "id", responseSchema), + post: { + operationId: "v1-create-thing", + requestBody: { + content: { + "application/json": { + schema: { + type: "object", + properties: { + status: statusEnum, + ...(options.sharedComponent + ? { shared: { $ref: "#/components/schemas/Shared" } } + : {}), + }, + required: ["status"], + }, + }, + }, + }, + responses: { "201": { description: "created" } }, + }, + }, + }, + } as unknown as OpenApiDocument; + } + + test("responses accept unknown enum values while requests stay closed", () => { + const document = enumFixture({ sharedComponent: false }); + const source = renderContracts(document, extractOperations(document)); + + expect(source).toContain( + "export const OpenString = Schema.declare((value: unknown): value is string & {} =>", + ); + const outputs = source.slice(source.indexOf("V1GetThingOutput")); + expect(outputs).toContain('Schema.Union([Schema.Literals(["A", "B"]), OpenString])'); + expect(source).toContain('"mode": Schema.Union([Schema.Literals(["A", "B"]), OpenString])'); + const inputs = source.slice( + source.indexOf("V1CreateThingInput"), + source.indexOf("V1GetThingInput"), + ); + expect(inputs).toContain('Schema.Literals(["A", "B"])'); + expect(inputs).not.toContain("OpenString"); + }); + + test("fails when an enum-bearing component is reachable from both a request and a response", () => { + const document = enumFixture({ sharedComponent: true }); + + expect(() => renderContracts(document, extractOperations(document))).toThrow( + 'Schema "Shared" has string enums and is reachable from both request and response schemas', + ); + }); + }); + test("preserves null in nullable anyOf JSON values without an explicit type", () => { expect( renderOpenApiSchema({ @@ -280,7 +397,7 @@ describe("generate", () => { ); }); - test("renderEffectClient emits both version namespaces with the shared method name and a versioned executor case", () => { + test("renderEffectClient emits both version namespaces with the shared method name", () => { const document = twoVersionFixture(); const operations = extractOperations(document); const source = renderEffectClient(operations); @@ -293,7 +410,7 @@ describe("generate", () => { expect(v1Block).toContain("listOrganizationMembers: ("); expect(v2Block).toContain("listOrganizationMembers: ("); - expect(source).toContain("api.v2.listOrganizationMembers(decoded)"); + expect(v2Block).toContain('"v2ListOrganizationMembers"'); }); test("renderContracts includes both versioned operation names and the raw kebab operationIds", () => { diff --git a/packages/api/scripts/openapi-overrides.json b/packages/api/scripts/openapi-overrides.json index f853c25808..0d43e170f2 100644 --- a/packages/api/scripts/openapi-overrides.json +++ b/packages/api/scripts/openapi-overrides.json @@ -2,7 +2,8 @@ { "op": "test", "path": "/components/schemas/CreateProviderResponse_Output/properties/saml/properties/attribute_mapping/required", - "value": ["keys"] + "value": ["keys"], + "$comment": "GoTrue serializes `attribute_mapping.keys` with `omitempty` and the platform passes the body through, so `keys` is absent when empty." }, { "op": "replace", @@ -12,7 +13,8 @@ { "op": "test", "path": "/components/schemas/GetProviderResponse_Output/properties/saml/properties/attribute_mapping/required", - "value": ["keys"] + "value": ["keys"], + "$comment": "GoTrue serializes `attribute_mapping.keys` with `omitempty` and the platform passes the body through, so `keys` is absent when empty." }, { "op": "replace", @@ -22,7 +24,8 @@ { "op": "test", "path": "/components/schemas/ListProvidersResponse_Output/properties/items/items/properties/saml/properties/attribute_mapping/required", - "value": ["keys"] + "value": ["keys"], + "$comment": "GoTrue serializes `attribute_mapping.keys` with `omitempty` and the platform passes the body through, so `keys` is absent when empty." }, { "op": "replace", @@ -32,7 +35,8 @@ { "op": "test", "path": "/components/schemas/UpdateProviderResponse_Output/properties/saml/properties/attribute_mapping/required", - "value": ["keys"] + "value": ["keys"], + "$comment": "GoTrue serializes `attribute_mapping.keys` with `omitempty` and the platform passes the body through, so `keys` is absent when empty." }, { "op": "replace", @@ -42,7 +46,8 @@ { "op": "test", "path": "/components/schemas/DeleteProviderResponse_Output/properties/saml/properties/attribute_mapping/required", - "value": ["keys"] + "value": ["keys"], + "$comment": "GoTrue serializes `attribute_mapping.keys` with `omitempty` and the platform passes the body through, so `keys` is absent when empty." }, { "op": "replace", @@ -52,7 +57,8 @@ { "op": "test", "path": "/components/schemas/UpdateCustomHostnameResponse_Output/required", - "value": ["status", "data"] + "value": ["status", "data"], + "$comment": "Hostnames stored before the upstream fix can lack `status`; the fix covers only new writes." }, { "op": "replace", @@ -61,522 +67,9 @@ }, { "op": "test", - "path": "/components/schemas/AuthConfigResponse_Output/required", - "value": [ - "api_max_request_duration", - "db_max_pool_size", - "db_max_pool_size_unit", - "disable_signup", - "external_anonymous_users_enabled", - "external_apple_additional_client_ids", - "external_apple_client_id", - "external_apple_email_optional", - "external_apple_enabled", - "external_apple_secret", - "external_azure_client_id", - "external_azure_email_optional", - "external_azure_enabled", - "external_azure_secret", - "external_azure_url", - "external_bitbucket_client_id", - "external_bitbucket_email_optional", - "external_bitbucket_enabled", - "external_bitbucket_secret", - "external_discord_client_id", - "external_discord_email_optional", - "external_discord_enabled", - "external_discord_secret", - "external_email_enabled", - "external_facebook_client_id", - "external_facebook_email_optional", - "external_facebook_enabled", - "external_facebook_secret", - "external_figma_client_id", - "external_figma_email_optional", - "external_figma_enabled", - "external_figma_secret", - "external_github_client_id", - "external_github_email_optional", - "external_github_enabled", - "external_github_secret", - "external_gitlab_client_id", - "external_gitlab_email_optional", - "external_gitlab_enabled", - "external_gitlab_secret", - "external_gitlab_url", - "external_google_additional_client_ids", - "external_google_client_id", - "external_google_email_optional", - "external_google_enabled", - "external_google_secret", - "external_google_skip_nonce_check", - "external_kakao_client_id", - "external_kakao_email_optional", - "external_kakao_enabled", - "external_kakao_secret", - "external_keycloak_client_id", - "external_keycloak_email_optional", - "external_keycloak_enabled", - "external_keycloak_secret", - "external_keycloak_url", - "external_linkedin_oidc_client_id", - "external_linkedin_oidc_email_optional", - "external_linkedin_oidc_enabled", - "external_linkedin_oidc_secret", - "external_slack_oidc_client_id", - "external_slack_oidc_email_optional", - "external_slack_oidc_enabled", - "external_slack_oidc_secret", - "external_notion_client_id", - "external_notion_email_optional", - "external_notion_enabled", - "external_notion_secret", - "external_phone_enabled", - "external_slack_client_id", - "external_slack_email_optional", - "external_slack_enabled", - "external_slack_secret", - "external_spotify_client_id", - "external_spotify_email_optional", - "external_spotify_enabled", - "external_spotify_secret", - "external_twitch_client_id", - "external_twitch_email_optional", - "external_twitch_enabled", - "external_twitch_secret", - "external_twitter_client_id", - "external_twitter_email_optional", - "external_twitter_enabled", - "external_twitter_secret", - "external_x_client_id", - "external_x_email_optional", - "external_x_enabled", - "external_x_secret", - "external_workos_client_id", - "external_workos_enabled", - "external_workos_secret", - "external_workos_url", - "external_web3_solana_enabled", - "external_web3_ethereum_enabled", - "external_zoom_client_id", - "external_zoom_email_optional", - "external_zoom_enabled", - "external_zoom_secret", - "hook_custom_access_token_enabled", - "hook_custom_access_token_uri", - "hook_custom_access_token_secrets", - "hook_mfa_verification_attempt_enabled", - "hook_mfa_verification_attempt_uri", - "hook_mfa_verification_attempt_secrets", - "hook_password_verification_attempt_enabled", - "hook_password_verification_attempt_uri", - "hook_password_verification_attempt_secrets", - "hook_send_sms_enabled", - "hook_send_sms_uri", - "hook_send_sms_secrets", - "hook_send_email_enabled", - "hook_send_email_uri", - "hook_send_email_secrets", - "hook_before_user_created_enabled", - "hook_before_user_created_uri", - "hook_before_user_created_secrets", - "hook_after_user_created_enabled", - "hook_after_user_created_uri", - "hook_after_user_created_secrets", - "jwt_exp", - "mailer_allow_unverified_email_sign_ins", - "mailer_autoconfirm", - "mailer_otp_exp", - "mailer_otp_length", - "mailer_secure_email_change_enabled", - "mailer_subjects_confirmation", - "mailer_subjects_email_change", - "mailer_subjects_invite", - "mailer_subjects_magic_link", - "mailer_subjects_reauthentication", - "mailer_subjects_recovery", - "mailer_subjects_password_changed_notification", - "mailer_subjects_email_changed_notification", - "mailer_subjects_phone_changed_notification", - "mailer_subjects_mfa_factor_enrolled_notification", - "mailer_subjects_mfa_factor_unenrolled_notification", - "mailer_subjects_identity_linked_notification", - "mailer_subjects_identity_unlinked_notification", - "mailer_templates_confirmation_content", - "mailer_templates_email_change_content", - "mailer_templates_invite_content", - "mailer_templates_magic_link_content", - "mailer_templates_reauthentication_content", - "mailer_templates_recovery_content", - "mailer_templates_password_changed_notification_content", - "mailer_templates_email_changed_notification_content", - "mailer_templates_phone_changed_notification_content", - "mailer_templates_mfa_factor_enrolled_notification_content", - "mailer_templates_mfa_factor_unenrolled_notification_content", - "mailer_templates_identity_linked_notification_content", - "mailer_templates_identity_unlinked_notification_content", - "mailer_notifications_password_changed_enabled", - "mailer_notifications_email_changed_enabled", - "mailer_notifications_phone_changed_enabled", - "mailer_notifications_mfa_factor_enrolled_enabled", - "mailer_notifications_mfa_factor_unenrolled_enabled", - "mailer_notifications_identity_linked_enabled", - "mailer_notifications_identity_unlinked_enabled", - "mfa_max_enrolled_factors", - "mfa_totp_enroll_enabled", - "mfa_totp_verify_enabled", - "mfa_phone_enroll_enabled", - "mfa_phone_verify_enabled", - "mfa_web_authn_enroll_enabled", - "mfa_web_authn_verify_enabled", - "passkey_enabled", - "webauthn_rp_display_name", - "webauthn_rp_id", - "webauthn_rp_origins", - "mfa_phone_otp_length", - "mfa_phone_template", - "mfa_phone_max_frequency", - "nimbus_oauth_client_id", - "nimbus_oauth_email_optional", - "nimbus_oauth_client_secret", - "password_hibp_enabled", - "password_min_length", - "password_required_characters", - "rate_limit_anonymous_users", - "rate_limit_email_sent", - "rate_limit_sms_sent", - "rate_limit_token_refresh", - "rate_limit_verify", - "rate_limit_otp", - "rate_limit_web3", - "refresh_token_rotation_enabled", - "saml_enabled", - "saml_external_url", - "saml_allow_encrypted_assertions", - "security_sb_forwarded_for_enabled", - "security_captcha_enabled", - "security_captcha_provider", - "security_captcha_secret", - "security_manual_linking_enabled", - "security_refresh_token_reuse_interval", - "security_update_password_require_current_password", - "security_update_password_require_reauthentication", - "sessions_inactivity_timeout", - "sessions_single_per_user", - "sessions_tags", - "sessions_timebox", - "site_url", - "sms_autoconfirm", - "sms_max_frequency", - "sms_messagebird_access_key", - "sms_messagebird_originator", - "sms_otp_exp", - "sms_otp_length", - "sms_provider", - "sms_template", - "sms_test_otp", - "sms_test_otp_valid_until", - "sms_textlocal_api_key", - "sms_textlocal_sender", - "sms_twilio_account_sid", - "sms_twilio_auth_token", - "sms_twilio_content_sid", - "sms_twilio_message_service_sid", - "sms_twilio_verify_account_sid", - "sms_twilio_verify_auth_token", - "sms_twilio_verify_message_service_sid", - "sms_vonage_api_key", - "sms_vonage_api_secret", - "sms_vonage_from", - "smtp_admin_email", - "smtp_host", - "smtp_max_frequency", - "smtp_pass", - "smtp_port", - "smtp_sender_name", - "smtp_user", - "uri_allow_list", - "oauth_server_enabled", - "oauth_server_allow_dynamic_registration", - "oauth_server_authorization_path", - "custom_oauth_enabled", - "custom_oauth_max_providers" - ] - }, - { - "op": "replace", - "path": "/components/schemas/AuthConfigResponse_Output/required", - "value": [ - "api_max_request_duration", - "db_max_pool_size", - "db_max_pool_size_unit", - "disable_signup", - "external_anonymous_users_enabled", - "external_apple_additional_client_ids", - "external_apple_client_id", - "external_apple_email_optional", - "external_apple_enabled", - "external_apple_secret", - "external_azure_client_id", - "external_azure_email_optional", - "external_azure_enabled", - "external_azure_secret", - "external_azure_url", - "external_bitbucket_client_id", - "external_bitbucket_email_optional", - "external_bitbucket_enabled", - "external_bitbucket_secret", - "external_discord_client_id", - "external_discord_email_optional", - "external_discord_enabled", - "external_discord_secret", - "external_email_enabled", - "external_facebook_client_id", - "external_facebook_email_optional", - "external_facebook_enabled", - "external_facebook_secret", - "external_figma_client_id", - "external_figma_email_optional", - "external_figma_enabled", - "external_figma_secret", - "external_github_client_id", - "external_github_email_optional", - "external_github_enabled", - "external_github_secret", - "external_gitlab_client_id", - "external_gitlab_email_optional", - "external_gitlab_enabled", - "external_gitlab_secret", - "external_gitlab_url", - "external_google_additional_client_ids", - "external_google_client_id", - "external_google_email_optional", - "external_google_enabled", - "external_google_secret", - "external_google_skip_nonce_check", - "external_kakao_client_id", - "external_kakao_email_optional", - "external_kakao_enabled", - "external_kakao_secret", - "external_keycloak_client_id", - "external_keycloak_email_optional", - "external_keycloak_enabled", - "external_keycloak_secret", - "external_keycloak_url", - "external_linkedin_oidc_client_id", - "external_linkedin_oidc_email_optional", - "external_linkedin_oidc_enabled", - "external_linkedin_oidc_secret", - "external_slack_oidc_client_id", - "external_slack_oidc_email_optional", - "external_slack_oidc_enabled", - "external_slack_oidc_secret", - "external_notion_client_id", - "external_notion_email_optional", - "external_notion_enabled", - "external_notion_secret", - "external_phone_enabled", - "external_slack_client_id", - "external_slack_email_optional", - "external_slack_enabled", - "external_slack_secret", - "external_spotify_client_id", - "external_spotify_email_optional", - "external_spotify_enabled", - "external_spotify_secret", - "external_twitch_client_id", - "external_twitch_email_optional", - "external_twitch_enabled", - "external_twitch_secret", - "external_twitter_client_id", - "external_twitter_email_optional", - "external_twitter_enabled", - "external_twitter_secret", - "external_x_client_id", - "external_x_email_optional", - "external_x_enabled", - "external_x_secret", - "external_workos_client_id", - "external_workos_enabled", - "external_workos_secret", - "external_workos_url", - "external_web3_solana_enabled", - "external_web3_ethereum_enabled", - "external_zoom_client_id", - "external_zoom_email_optional", - "external_zoom_enabled", - "external_zoom_secret", - "hook_custom_access_token_enabled", - "hook_custom_access_token_uri", - "hook_custom_access_token_secrets", - "hook_mfa_verification_attempt_enabled", - "hook_mfa_verification_attempt_uri", - "hook_mfa_verification_attempt_secrets", - "hook_password_verification_attempt_enabled", - "hook_password_verification_attempt_uri", - "hook_password_verification_attempt_secrets", - "hook_send_sms_enabled", - "hook_send_sms_uri", - "hook_send_sms_secrets", - "hook_send_email_enabled", - "hook_send_email_uri", - "hook_send_email_secrets", - "hook_before_user_created_enabled", - "hook_before_user_created_uri", - "hook_before_user_created_secrets", - "hook_after_user_created_enabled", - "hook_after_user_created_uri", - "hook_after_user_created_secrets", - "jwt_exp", - "mailer_allow_unverified_email_sign_ins", - "mailer_autoconfirm", - "mailer_otp_exp", - "mailer_otp_length", - "mailer_secure_email_change_enabled", - "mailer_subjects_confirmation", - "mailer_subjects_email_change", - "mailer_subjects_invite", - "mailer_subjects_magic_link", - "mailer_subjects_reauthentication", - "mailer_subjects_recovery", - "mailer_subjects_password_changed_notification", - "mailer_subjects_email_changed_notification", - "mailer_subjects_phone_changed_notification", - "mailer_subjects_mfa_factor_enrolled_notification", - "mailer_subjects_mfa_factor_unenrolled_notification", - "mailer_subjects_identity_linked_notification", - "mailer_subjects_identity_unlinked_notification", - "mailer_templates_confirmation_content", - "mailer_templates_email_change_content", - "mailer_templates_invite_content", - "mailer_templates_magic_link_content", - "mailer_templates_reauthentication_content", - "mailer_templates_recovery_content", - "mailer_templates_password_changed_notification_content", - "mailer_templates_email_changed_notification_content", - "mailer_templates_phone_changed_notification_content", - "mailer_templates_mfa_factor_enrolled_notification_content", - "mailer_templates_mfa_factor_unenrolled_notification_content", - "mailer_templates_identity_linked_notification_content", - "mailer_templates_identity_unlinked_notification_content", - "mailer_notifications_password_changed_enabled", - "mailer_notifications_email_changed_enabled", - "mailer_notifications_phone_changed_enabled", - "mailer_notifications_mfa_factor_enrolled_enabled", - "mailer_notifications_mfa_factor_unenrolled_enabled", - "mailer_notifications_identity_linked_enabled", - "mailer_notifications_identity_unlinked_enabled", - "mfa_max_enrolled_factors", - "mfa_totp_enroll_enabled", - "mfa_totp_verify_enabled", - "mfa_phone_enroll_enabled", - "mfa_phone_verify_enabled", - "mfa_web_authn_enroll_enabled", - "mfa_web_authn_verify_enabled", - "passkey_enabled", - "webauthn_rp_display_name", - "webauthn_rp_id", - "webauthn_rp_origins", - "mfa_phone_otp_length", - "mfa_phone_template", - "mfa_phone_max_frequency", - "nimbus_oauth_client_id", - "nimbus_oauth_client_secret", - "password_hibp_enabled", - "password_min_length", - "password_required_characters", - "rate_limit_anonymous_users", - "rate_limit_email_sent", - "rate_limit_sms_sent", - "rate_limit_token_refresh", - "rate_limit_verify", - "rate_limit_otp", - "rate_limit_web3", - "refresh_token_rotation_enabled", - "saml_enabled", - "saml_external_url", - "saml_allow_encrypted_assertions", - "security_sb_forwarded_for_enabled", - "security_captcha_enabled", - "security_captcha_provider", - "security_captcha_secret", - "security_manual_linking_enabled", - "security_refresh_token_reuse_interval", - "security_update_password_require_current_password", - "security_update_password_require_reauthentication", - "sessions_inactivity_timeout", - "sessions_single_per_user", - "sessions_tags", - "sessions_timebox", - "site_url", - "sms_autoconfirm", - "sms_max_frequency", - "sms_messagebird_access_key", - "sms_messagebird_originator", - "sms_otp_exp", - "sms_otp_length", - "sms_provider", - "sms_template", - "sms_test_otp", - "sms_test_otp_valid_until", - "sms_textlocal_api_key", - "sms_textlocal_sender", - "sms_twilio_account_sid", - "sms_twilio_auth_token", - "sms_twilio_content_sid", - "sms_twilio_message_service_sid", - "sms_twilio_verify_account_sid", - "sms_twilio_verify_auth_token", - "sms_twilio_verify_message_service_sid", - "sms_vonage_api_key", - "sms_vonage_api_secret", - "sms_vonage_from", - "smtp_admin_email", - "smtp_host", - "smtp_max_frequency", - "smtp_pass", - "smtp_port", - "smtp_sender_name", - "smtp_user", - "uri_allow_list", - "oauth_server_enabled", - "oauth_server_allow_dynamic_registration", - "oauth_server_authorization_path", - "custom_oauth_enabled", - "custom_oauth_max_providers" - ] - }, - { - "op": "replace", - "path": "/components/schemas/FunctionResponse_Output/properties/import_map_path", - "value": { - "type": "string", - "nullable": true - } - }, - { - "op": "replace", - "path": "/components/schemas/BulkUpdateFunctionResponse_Output/properties/functions/items/properties/import_map_path", - "value": { - "type": "string", - "nullable": true - } - }, - { - "op": "replace", - "path": "/components/schemas/DeployFunctionResponse_Output/properties/import_map_path", - "value": { - "type": "string", - "nullable": true - } - }, - { - "op": "replace", - "path": "/components/schemas/FunctionSlugResponse_Output/properties/import_map_path", - "value": { - "type": "string", - "nullable": true - } + "path": "/components/schemas/UpdateAuthConfigBody/properties/sessions_tags/pattern", + "value": "^\\s*([a-zA-Z0-9_-]+(\\s*,+\\s*)?)*\\s*$", + "$comment": "The platform publishes this regex with nested quantifiers, which backtrack catastrophically on adversarial input; the replacement accepts the same format in linear time." }, { "op": "replace", @@ -584,63 +77,15 @@ "value": "^(?:\\s*|\\s*[a-zA-Z0-9_-]+(?:\\s*,+\\s*[a-zA-Z0-9_-]+)*(?:\\s*,+)?\\s*)$" }, { - "op": "replace", + "op": "test", "path": "/components/schemas/UpdateAuthConfigBody/properties/sms_test_otp/pattern", - "value": "^(?:[0-9]{1,15}=(?:[0-9]+,[0-9]{1,15}=|[0-9]{2,}=)*[0-9]+,?)?$" - }, - { - "op": "remove", - "path": "/paths/~1v2~1projects~1{ref}~1webhooks~1endpoints", - "$comment": "The v2 webhook response schemas use const keywords in the OpenAPI 3.0 document that the Effect schema converter rejects; keep these paths out of the generated client until the converter supports them." - }, - { - "op": "remove", - "path": "/paths/~1v2~1projects~1{ref}~1webhooks~1endpoints~1{id}" - }, - { - "op": "remove", - "path": "/paths/~1v2~1projects~1{ref}~1webhooks~1endpoints~1{id}~1deliveries" - }, - { - "op": "remove", - "path": "/paths/~1v2~1projects~1{ref}~1webhooks~1endpoints~1{id}~1test" - }, - { - "op": "remove", - "path": "/paths/~1v2~1projects~1{ref}~1webhooks~1deliveries~1{id}" - }, - { - "op": "remove", - "path": "/paths/~1v2~1projects~1{ref}~1webhooks~1deliveries~1{id}~1retry" - }, - { - "op": "remove", - "path": "/paths/~1v2~1organizations~1{slug}~1webhooks~1endpoints", - "$comment": "The v2 webhook response schemas use const keywords in the OpenAPI 3.0 document that the Effect schema converter rejects; keep these paths out of the generated client until the converter supports them." - }, - { - "op": "remove", - "path": "/paths/~1v2~1organizations~1{slug}~1webhooks~1endpoints~1{id}" - }, - { - "op": "remove", - "path": "/paths/~1v2~1organizations~1{slug}~1webhooks~1endpoints~1{id}~1deliveries" - }, - { - "op": "remove", - "path": "/paths/~1v2~1organizations~1{slug}~1webhooks~1endpoints~1{id}~1test" - }, - { - "op": "remove", - "path": "/paths/~1v2~1organizations~1{slug}~1webhooks~1deliveries~1{id}" - }, - { - "op": "remove", - "path": "/paths/~1v2~1organizations~1{slug}~1webhooks~1deliveries~1{id}~1retry" + "value": "^([0-9]{1,15}=[0-9]+,?)*$", + "$comment": "The platform publishes this regex with nested quantifiers, which backtrack catastrophically on adversarial input; the replacement accepts the same format in linear time." }, { - "op": "remove", - "path": "/components/schemas/APIErrorObject" + "op": "replace", + "path": "/components/schemas/UpdateAuthConfigBody/properties/sms_test_otp/pattern", + "value": "^(?:[0-9]{1,15}=(?:[0-9]+,[0-9]{1,15}=|[0-9]{2,}=)*[0-9]+,?)?$" }, { "op": "test", @@ -648,7 +93,8 @@ "value": { "deprecated": true, "type": "null" - } + }, + "$comment": "CLI-2180: the public spec deliberately hides this field (upstream marks it deprecated/null) even though POST /v1/projects accepts it; enum mirrors CreateBranchBody.release_channel in the same spec." }, { "op": "replace", @@ -657,8 +103,7 @@ "type": "string", "enum": ["internal", "alpha", "beta", "ga", "withdrawn", "preview"], "description": "Release channel. If not provided, GA will be used." - }, - "$comment": "CLI-2180: the public spec deliberately hides this field (upstream marks it deprecated/null) even though POST /v1/projects accepts it; enum mirrors CreateBranchBody.release_channel in the same spec." + } }, { "op": "test", @@ -666,7 +111,8 @@ "value": { "deprecated": true, "type": "null" - } + }, + "$comment": "CLI-2180: the public spec deliberately hides this field (upstream marks it deprecated/null) even though POST /v1/projects accepts it; enum mirrors CreateBranchBody.postgres_engine in the same spec." }, { "op": "replace", @@ -675,7 +121,36 @@ "type": "string", "enum": ["15", "17", "17-oriole"], "description": "Postgres engine version. If not provided, the latest version will be used." - }, - "$comment": "CLI-2180: the public spec deliberately hides this field (upstream marks it deprecated/null) even though POST /v1/projects accepts it; enum mirrors CreateBranchBody.postgres_engine in the same spec." + } + }, + { + "op": "add", + "path": "/components/schemas/SnippetList_Output/properties/data/items/properties/owner/nullable", + "value": true, + "$comment": "The platform LEFT JOINs the owner and last-updated-by users, so these are null when the user row is gone." + }, + { + "op": "add", + "path": "/components/schemas/SnippetList_Output/properties/data/items/properties/updated_by/nullable", + "value": true, + "$comment": "The platform LEFT JOINs the owner and last-updated-by users, so these are null when the user row is gone." + }, + { + "op": "add", + "path": "/components/schemas/SnippetResponse_Output/properties/owner/nullable", + "value": true, + "$comment": "The platform LEFT JOINs the owner and last-updated-by users, so these are null when the user row is gone." + }, + { + "op": "add", + "path": "/components/schemas/SnippetResponse_Output/properties/updated_by/nullable", + "value": true, + "$comment": "The platform LEFT JOINs the owner and last-updated-by users, so these are null when the user row is gone." + }, + { + "op": "add", + "path": "/components/schemas/SnippetResponse_Output/properties/content/nullable", + "value": true, + "$comment": "The platform returns a null `content` for a snippet that has no stored content." } ] diff --git a/packages/api/src/effect.ts b/packages/api/src/effect.ts index e86a7d4ed4..d3633d2940 100644 --- a/packages/api/src/effect.ts +++ b/packages/api/src/effect.ts @@ -14,6 +14,8 @@ import { export type { SupabaseApiError, SupabaseApiInputErrorSource, + SupabaseApiRawRequestOptions, + SupabaseApiRequestOptions, SupabaseApiRetryOptions, } from "./internal/client.ts"; export { @@ -33,7 +35,6 @@ export { operationDefinitions, } from "./generated/contracts.ts"; export * from "./generated/contracts.ts"; -export { executeApiClientOperation } from "./generated/effect-client.ts"; export const makeApiClient = (config: SupabaseApiConfig = {}, options?: SupabaseApiClientOptions) => Effect.map(makeSupabaseApiClient(config, options), (client) => ({ diff --git a/packages/api/src/effect.unit.test.ts b/packages/api/src/effect.unit.test.ts index 466a97815a..93249f8096 100644 --- a/packages/api/src/effect.unit.test.ts +++ b/packages/api/src/effect.unit.test.ts @@ -167,13 +167,40 @@ describe("makeApiClient", () => { ref: "abcdefghijklmnopqrst", function_slug: "hello-world", }, - { Accept: "multipart/form-data" }, + { headers: { Accept: "multipart/form-data" } }, ), ); expect(accept).toBe("multipart/form-data"); }); + test("forwards per-request options through generated typed methods", async () => { + let seen: { url: string; authorization: string | undefined } | undefined; + + const client = await Effect.runPromise( + makeApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + seen = { url: request.url, authorization: request.headers.authorization }; + return Effect.succeed(jsonResponse(request, 200, [])); + }), + ), + ), + ); + + await Effect.runPromise( + client.v1.listAllProjects( + {}, + { baseUrl: "https://api.example.test", accessToken: "per-request-token" }, + ), + ); + + expect(seen).toEqual({ + url: "https://api.example.test/v1/projects", + authorization: "Bearer per-request-token", + }); + }); + test("uses the default API URL when baseUrl is omitted", async () => { const seenRequests: Array<{ method: string; url: string }> = []; diff --git a/packages/api/src/generated/contracts.ts b/packages/api/src/generated/contracts.ts index c39cbf9be9..6a546ee8b9 100644 --- a/packages/api/src/generated/contracts.ts +++ b/packages/api/src/generated/contracts.ts @@ -1,9 +1,13 @@ import * as Schema from "effect/Schema"; +// open string helper +export const OpenString = Schema.declare( + (value: unknown): value is string & {} => typeof value === "string", +); // non-recursive definitions export const SupavisorConfigResponse_Output = Schema.Struct({ identifier: Schema.String, - database_type: Schema.Literals(["PRIMARY", "READ_REPLICA"]), + database_type: Schema.Union([Schema.Literals(["PRIMARY", "READ_REPLICA"]), OpenString]), is_using_scram_auth: Schema.Boolean, db_user: Schema.String, db_host: Schema.String, @@ -49,13 +53,16 @@ export const SupavisorConfigResponse_Output = Schema.Struct({ ), Schema.Null, ]), - pool_mode: Schema.Literals(["transaction", "session"]), + pool_mode: Schema.Union([Schema.Literals(["transaction", "session"]), OpenString]), }).annotate({ identifier: "SupavisorConfigResponse_Output" }); export const ApiKeyResponse_Output = Schema.Struct({ api_key: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), type: Schema.optionalKey( - Schema.Union([Schema.Literals(["legacy", "publishable", "secret"]), Schema.Null]), + Schema.Union([ + Schema.Union([Schema.Literals(["legacy", "publishable", "secret"]), Schema.Null]), + OpenString, + ]), ), prefix: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), name: Schema.String, @@ -75,18 +82,21 @@ export const ApiKeyResponse_Output = Schema.Struct({ ), }).annotate({ identifier: "ApiKeyResponse_Output" }); export const V1ServiceHealthResponse_Output = Schema.Struct({ - name: Schema.Literals([ - "auth", - "db", - "db_postgres_user", - "pooler", - "realtime", - "rest", - "storage", - "pg_bouncer", + name: Schema.Union([ + Schema.Literals([ + "auth", + "db", + "db_postgres_user", + "pooler", + "realtime", + "rest", + "storage", + "pg_bouncer", + ]), + OpenString, ]), healthy: Schema.Boolean.annotate({ description: "Deprecated. Use `status` instead." }), - status: Schema.Literals(["COMING_UP", "ACTIVE_HEALTHY", "UNHEALTHY"]), + status: Schema.Union([Schema.Literals(["COMING_UP", "ACTIVE_HEALTHY", "UNHEALTHY"]), OpenString]), info: Schema.optionalKey( Schema.Union([ Schema.Struct({ @@ -152,16 +162,19 @@ export const BranchResponse_Output = Schema.Struct({ }).check(Schema.isFinite().annotate({ expected: "a finite number" })), ), persistent: Schema.Boolean, - status: Schema.Literals([ - "CREATING_PROJECT", - "RUNNING_MIGRATIONS", - "MIGRATIONS_PASSED", - "MIGRATIONS_FAILED", - "FUNCTIONS_DEPLOYED", - "FUNCTIONS_FAILED", - ]).annotate({ - description: "This field is deprecated. List action runs to get branch status instead.", - }), + status: Schema.Union([ + Schema.Literals([ + "CREATING_PROJECT", + "RUNNING_MIGRATIONS", + "MIGRATIONS_PASSED", + "MIGRATIONS_FAILED", + "FUNCTIONS_DEPLOYED", + "FUNCTIONS_FAILED", + ]).annotate({ + description: "This field is deprecated. List action runs to get branch status instead.", + }), + OpenString, + ]), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), review_requested_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), @@ -169,22 +182,25 @@ export const BranchResponse_Output = Schema.Struct({ notify_url: Schema.optionalKey(Schema.String.annotate({ format: "uri" })), deletion_scheduled_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), preview_project_status: Schema.optionalKey( - Schema.Literals([ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING", + Schema.Union([ + Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]), + OpenString, ]), ), }).annotate({ identifier: "BranchResponse_Output" }); @@ -200,7 +216,7 @@ export const FunctionResponse_Output = Schema.Struct({ id: Schema.String, slug: Schema.String, name: Schema.String, - status: Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), + status: Schema.Union([Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), OpenString]), version: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -239,7 +255,7 @@ export const FunctionResponse_Output = Schema.Struct({ verify_jwt: Schema.optionalKey(Schema.Boolean), import_map: Schema.optionalKey(Schema.Boolean), entrypoint_path: Schema.optionalKey(Schema.String), - import_map_path: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + import_map_path: Schema.optionalKey(Schema.String), ezbr_sha256: Schema.optionalKey(Schema.String), }).annotate({ identifier: "FunctionResponse_Output" }); export const OrganizationResponseV1_Output = Schema.Struct({ @@ -276,22 +292,25 @@ export const V1ProjectWithDatabaseResponse_Output = Schema.Struct({ name: Schema.String.annotate({ description: "Name of your project" }), region: Schema.String.annotate({ description: "Region of your project" }), created_at: Schema.String.annotate({ description: "Creation timestamp" }), - status: Schema.Literals([ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING", + status: Schema.Union([ + Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]), + OpenString, ]), database: Schema.Struct({ host: Schema.String.annotate({ description: "Database host" }), @@ -468,12 +487,15 @@ export const V1ActivateCustomHostnameInput = Schema.Struct({ }); export const V1ActivateCustomHostnameOutput = Schema.Struct({ status: Schema.optionalKey( - Schema.Literals([ - "1_not_started", - "2_initiated", - "3_challenge_verified", - "4_origin_setup_completed", - "5_services_reconfigured", + Schema.Union([ + Schema.Literals([ + "1_not_started", + "2_initiated", + "3_challenge_verified", + "4_origin_setup_completed", + "5_services_reconfigured", + ]), + OpenString, ]), ), custom_hostname: Schema.optionalKey(Schema.String), @@ -839,7 +861,7 @@ export const V1BulkUpdateFunctionsOutput = Schema.Struct({ id: Schema.String, slug: Schema.String, name: Schema.String, - status: Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), + status: Schema.Union([Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), OpenString]), version: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -878,7 +900,7 @@ export const V1BulkUpdateFunctionsOutput = Schema.Struct({ verify_jwt: Schema.optionalKey(Schema.Boolean), import_map: Schema.optionalKey(Schema.Boolean), entrypoint_path: Schema.optionalKey(Schema.String), - import_map_path: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + import_map_path: Schema.optionalKey(Schema.String), ezbr_sha256: Schema.optionalKey(Schema.String), }), ), @@ -1035,16 +1057,19 @@ export const V1CreateABranchOutput = Schema.Struct({ }).check(Schema.isFinite().annotate({ expected: "a finite number" })), ), persistent: Schema.Boolean, - status: Schema.Literals([ - "CREATING_PROJECT", - "RUNNING_MIGRATIONS", - "MIGRATIONS_PASSED", - "MIGRATIONS_FAILED", - "FUNCTIONS_DEPLOYED", - "FUNCTIONS_FAILED", - ]).annotate({ - description: "This field is deprecated. List action runs to get branch status instead.", - }), + status: Schema.Union([ + Schema.Literals([ + "CREATING_PROJECT", + "RUNNING_MIGRATIONS", + "MIGRATIONS_PASSED", + "MIGRATIONS_FAILED", + "FUNCTIONS_DEPLOYED", + "FUNCTIONS_FAILED", + ]).annotate({ + description: "This field is deprecated. List action runs to get branch status instead.", + }), + OpenString, + ]), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), review_requested_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), @@ -1052,22 +1077,25 @@ export const V1CreateABranchOutput = Schema.Struct({ notify_url: Schema.optionalKey(Schema.String.annotate({ format: "uri" })), deletion_scheduled_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), preview_project_status: Schema.optionalKey( - Schema.Literals([ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING", + Schema.Union([ + Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]), + OpenString, ]), ), }); @@ -1102,7 +1130,7 @@ export const V1CreateAFunctionOutput = Schema.Struct({ id: Schema.String, slug: Schema.String, name: Schema.String, - status: Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), + status: Schema.Union([Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), OpenString]), version: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -1141,7 +1169,7 @@ export const V1CreateAFunctionOutput = Schema.Struct({ verify_jwt: Schema.optionalKey(Schema.Boolean), import_map: Schema.optionalKey(Schema.Boolean), entrypoint_path: Schema.optionalKey(Schema.String), - import_map_path: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + import_map_path: Schema.optionalKey(Schema.String), ezbr_sha256: Schema.optionalKey(Schema.String), }); export const V1CreateAProjectInput = Schema.Struct({ @@ -1307,22 +1335,25 @@ export const V1CreateAProjectOutput = Schema.Struct({ name: Schema.String.annotate({ description: "Name of your project" }), region: Schema.String.annotate({ description: "Region of your project" }), created_at: Schema.String.annotate({ description: "Creation timestamp" }), - status: Schema.Literals([ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING", + status: Schema.Union([ + Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]), + OpenString, ]), }); export const V1CreateASsoProviderInput = Schema.Struct({ @@ -1386,11 +1417,14 @@ export const V1CreateASsoProviderOutput = Schema.Struct({ }), ), name_id_format: Schema.optionalKey( - Schema.Literals([ - "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + Schema.Union([ + Schema.Literals([ + "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + ]), + OpenString, ]), ), }), @@ -1446,8 +1480,11 @@ export const V1CreateLegacySigningKeyOutput = Schema.Struct({ "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", }), ), - algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), - status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + algorithm: Schema.Union([Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), OpenString]), + status: Schema.Union([ + Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + OpenString, + ]), public_jwk: Schema.Json.annotate({ expected: "JSON value" }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), @@ -1521,7 +1558,10 @@ export const V1CreateProjectApiKeyOutput = Schema.Struct({ api_key: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), type: Schema.optionalKey( - Schema.Union([Schema.Literals(["legacy", "publishable", "secret"]), Schema.Null]), + Schema.Union([ + Schema.Union([Schema.Literals(["legacy", "publishable", "secret"]), Schema.Null]), + OpenString, + ]), ), prefix: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), name: Schema.String, @@ -1736,8 +1776,11 @@ export const V1CreateProjectSigningKeyOutput = Schema.Struct({ "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", }), ), - algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), - status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + algorithm: Schema.Union([Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), OpenString]), + status: Schema.Union([ + Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + OpenString, + ]), public_jwk: Schema.Json.annotate({ expected: "JSON value" }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), @@ -1797,7 +1840,10 @@ export const V1CreateRestorePointInput = Schema.Struct({ }); export const V1CreateRestorePointOutput = Schema.Struct({ name: Schema.String, - status: Schema.Literals(["AVAILABLE", "PENDING", "REMOVED", "FAILED"]), + status: Schema.Union([ + Schema.Literals(["AVAILABLE", "PENDING", "REMOVED", "FAILED"]), + OpenString, + ]), completed_on: Schema.Union([Schema.String.annotate({ format: "date-time" }), Schema.Null]), }); export const V1DeactivateVanitySubdomainConfigInput = Schema.Struct({ @@ -1949,11 +1995,14 @@ export const V1DeleteASsoProviderOutput = Schema.Struct({ }), ), name_id_format: Schema.optionalKey( - Schema.Literals([ - "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + Schema.Union([ + Schema.Literals([ + "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + ]), + OpenString, ]), ), }), @@ -2085,7 +2134,10 @@ export const V1DeleteProjectApiKeyOutput = Schema.Struct({ api_key: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), type: Schema.optionalKey( - Schema.Union([Schema.Literals(["legacy", "publishable", "secret"]), Schema.Null]), + Schema.Union([ + Schema.Union([Schema.Literals(["legacy", "publishable", "secret"]), Schema.Null]), + OpenString, + ]), ), prefix: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), name: Schema.String, @@ -2197,7 +2249,7 @@ export const V1DeployAFunctionOutput = Schema.Struct({ id: Schema.String, slug: Schema.String, name: Schema.String, - status: Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), + status: Schema.Union([Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), OpenString]), version: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -2240,7 +2292,7 @@ export const V1DeployAFunctionOutput = Schema.Struct({ verify_jwt: Schema.optionalKey(Schema.Boolean), import_map: Schema.optionalKey(Schema.Boolean), entrypoint_path: Schema.optionalKey(Schema.String), - import_map_path: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + import_map_path: Schema.optionalKey(Schema.String), ezbr_sha256: Schema.optionalKey(Schema.String), }); export const V1DiffABranchInput = Schema.Struct({ @@ -2441,16 +2493,19 @@ export const V1GetABranchOutput = Schema.Struct({ }).check(Schema.isFinite().annotate({ expected: "a finite number" })), ), persistent: Schema.Boolean, - status: Schema.Literals([ - "CREATING_PROJECT", - "RUNNING_MIGRATIONS", - "MIGRATIONS_PASSED", - "MIGRATIONS_FAILED", - "FUNCTIONS_DEPLOYED", - "FUNCTIONS_FAILED", - ]).annotate({ - description: "This field is deprecated. List action runs to get branch status instead.", - }), + status: Schema.Union([ + Schema.Literals([ + "CREATING_PROJECT", + "RUNNING_MIGRATIONS", + "MIGRATIONS_PASSED", + "MIGRATIONS_FAILED", + "FUNCTIONS_DEPLOYED", + "FUNCTIONS_FAILED", + ]).annotate({ + description: "This field is deprecated. List action runs to get branch status instead.", + }), + OpenString, + ]), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), review_requested_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), @@ -2458,22 +2513,25 @@ export const V1GetABranchOutput = Schema.Struct({ notify_url: Schema.optionalKey(Schema.String.annotate({ format: "uri" })), deletion_scheduled_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), preview_project_status: Schema.optionalKey( - Schema.Literals([ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING", + Schema.Union([ + Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]), + OpenString, ]), ), }); @@ -2509,22 +2567,25 @@ export const V1GetABranchConfigOutput = Schema.Struct({ postgres_version: Schema.String, postgres_engine: Schema.String, release_channel: Schema.String, - status: Schema.Literals([ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING", + status: Schema.Union([ + Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]), + OpenString, ]), db_host: Schema.String, db_port: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) @@ -2560,7 +2621,7 @@ export const V1GetAFunctionOutput = Schema.Struct({ id: Schema.String, slug: Schema.String, name: Schema.String, - status: Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), + status: Schema.Union([Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), OpenString]), version: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -2599,7 +2660,7 @@ export const V1GetAFunctionOutput = Schema.Struct({ verify_jwt: Schema.optionalKey(Schema.Boolean), import_map: Schema.optionalKey(Schema.Boolean), entrypoint_path: Schema.optionalKey(Schema.String), - import_map_path: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + import_map_path: Schema.optionalKey(Schema.String), ezbr_sha256: Schema.optionalKey(Schema.String), }); export const V1GetAFunctionBodyInput = Schema.Struct({ @@ -2665,31 +2726,40 @@ export const V1GetASnippetOutput = Schema.Struct({ inserted_at: Schema.String, updated_at: Schema.String, type: Schema.Literal("sql"), - visibility: Schema.Literals(["user", "project", "org", "public"]), + visibility: Schema.Union([Schema.Literals(["user", "project", "org", "public"]), OpenString]), name: Schema.String, description: Schema.Union([Schema.String, Schema.Null]), project: Schema.Struct({ id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), name: Schema.String, }), - owner: Schema.Struct({ - id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), - username: Schema.String, - }), - updated_by: Schema.Struct({ - id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), - username: Schema.String, - }), + owner: Schema.Union([ + Schema.Struct({ + id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + username: Schema.String, + }), + Schema.Null, + ]), + updated_by: Schema.Union([ + Schema.Struct({ + id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + username: Schema.String, + }), + Schema.Null, + ]), favorite: Schema.Boolean, - content: Schema.Struct({ - favorite: Schema.optionalKey( - Schema.Boolean.annotate({ - description: "Deprecated: Rely on root-level favorite property instead.", - }), - ), - schema_version: Schema.optionalKey(Schema.String), - sql: Schema.String, - }), + content: Schema.Union([ + Schema.Struct({ + favorite: Schema.optionalKey( + Schema.Boolean.annotate({ + description: "Deprecated: Rely on root-level favorite property instead.", + }), + ), + schema_version: Schema.optionalKey(Schema.String), + sql: Schema.String, + }), + Schema.Null, + ]), }); export const V1GetASsoProviderInput = Schema.Struct({ ref: Schema.String.check( @@ -2738,11 +2808,14 @@ export const V1GetASsoProviderOutput = Schema.Struct({ }), ), name_id_format: Schema.optionalKey( - Schema.Literals([ - "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + Schema.Union([ + Schema.Literals([ + "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + ]), + OpenString, ]), ), }), @@ -2778,15 +2851,21 @@ export const V1GetActionRunOutput = Schema.Struct({ branch_id: Schema.String, run_steps: Schema.Array( Schema.Struct({ - name: Schema.Literals(["clone", "pull", "health", "configure", "migrate", "seed", "deploy"]), - status: Schema.Literals([ - "CREATED", - "DEAD", - "EXITED", - "PAUSED", - "REMOVING", - "RESTARTING", - "RUNNING", + name: Schema.Union([ + Schema.Literals(["clone", "pull", "health", "configure", "migrate", "seed", "deploy"]), + OpenString, + ]), + status: Schema.Union([ + Schema.Literals([ + "CREATED", + "DEAD", + "EXITED", + "PAUSED", + "REMOVING", + "RESTARTING", + "RUNNING", + ]), + OpenString, ]), created_at: Schema.String, updated_at: Schema.String, @@ -2860,72 +2939,83 @@ export const V1GetAllProjectsForOrganizationOutput = Schema.Struct({ cloud_provider: Schema.String, region: Schema.String, is_branch: Schema.Boolean, - status: Schema.Literals([ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING", + status: Schema.Union([ + Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]), + OpenString, ]), inserted_at: Schema.String, databases: Schema.Array( Schema.Struct({ infra_compute_size: Schema.optionalKey( - Schema.Literals([ - "pico", - "nano", - "micro", - "small", - "medium", - "large", - "xlarge", - "2xlarge", - "4xlarge", - "8xlarge", - "12xlarge", - "16xlarge", - "24xlarge", - "24xlarge_optimized_memory", - "24xlarge_optimized_cpu", - "24xlarge_high_memory", - "48xlarge", - "48xlarge_optimized_memory", - "48xlarge_optimized_cpu", - "48xlarge_high_memory", + Schema.Union([ + Schema.Literals([ + "pico", + "nano", + "micro", + "small", + "medium", + "large", + "xlarge", + "2xlarge", + "4xlarge", + "8xlarge", + "12xlarge", + "16xlarge", + "24xlarge", + "24xlarge_optimized_memory", + "24xlarge_optimized_cpu", + "24xlarge_high_memory", + "48xlarge", + "48xlarge_optimized_memory", + "48xlarge_optimized_cpu", + "48xlarge_high_memory", + ]), + OpenString, ]), ), region: Schema.String, - status: Schema.Literals([ - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UNKNOWN", - "INIT_READ_REPLICA", - "INIT_READ_REPLICA_FAILED", - "RESTARTING", - "RESIZING", + status: Schema.Union([ + Schema.Literals([ + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UNKNOWN", + "INIT_READ_REPLICA", + "INIT_READ_REPLICA_FAILED", + "RESTARTING", + "RESIZING", + ]), + OpenString, ]), cloud_provider: Schema.String, identifier: Schema.String, - type: Schema.Literals(["PRIMARY", "READ_REPLICA"]), + type: Schema.Union([Schema.Literals(["PRIMARY", "READ_REPLICA"]), OpenString]), disk_volume_size_gb: Schema.optionalKey( Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), - disk_type: Schema.optionalKey(Schema.Literals(["gp3", "io2"])), + disk_type: Schema.optionalKey( + Schema.Union([Schema.Literals(["gp3", "io2"]), OpenString]), + ), disk_throughput_mbps: Schema.optionalKey( Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), @@ -2956,16 +3046,24 @@ export const V1GetAnOrganizationInput = Schema.Struct({ export const V1GetAnOrganizationOutput = Schema.Struct({ id: Schema.String, name: Schema.String, - plan: Schema.optionalKey(Schema.Literals(["free", "pro", "team", "enterprise", "platform"])), + plan: Schema.optionalKey( + Schema.Union([Schema.Literals(["free", "pro", "team", "enterprise", "platform"]), OpenString]), + ), opt_in_tags: Schema.Array( - Schema.Literals([ - "AI_SQL_GENERATOR_OPT_IN", - "AI_DATA_GENERATOR_OPT_IN", - "AI_LOG_GENERATOR_OPT_IN", + Schema.Union([ + Schema.Literals([ + "AI_SQL_GENERATOR_OPT_IN", + "AI_DATA_GENERATOR_OPT_IN", + "AI_LOG_GENERATOR_OPT_IN", + ]), + OpenString, ]), ), allowed_release_channels: Schema.Array( - Schema.Literals(["internal", "alpha", "beta", "ga", "withdrawn", "preview"]), + Schema.Union([ + Schema.Literals(["internal", "alpha", "beta", "ga", "withdrawn", "preview"]), + OpenString, + ]), ), }); export const V1GetAuthServiceConfigInput = Schema.Struct({ @@ -3010,7 +3108,10 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ ), Schema.Null, ]), - db_max_pool_size_unit: Schema.Union([Schema.Literals(["connections", "percent"]), Schema.Null]), + db_max_pool_size_unit: Schema.Union([ + Schema.Union([Schema.Literals(["connections", "percent"]), Schema.Null]), + OpenString, + ]), disable_signup: Schema.Union([Schema.Boolean, Schema.Null]), external_anonymous_users_enabled: Schema.Union([Schema.Boolean, Schema.Null]), external_apple_additional_client_ids: Schema.Union([Schema.String, Schema.Null]), @@ -3266,7 +3367,7 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), nimbus_oauth_client_id: Schema.Union([Schema.String, Schema.Null]), - nimbus_oauth_email_optional: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), + nimbus_oauth_email_optional: Schema.Union([Schema.Boolean, Schema.Null]), nimbus_oauth_client_secret: Schema.Union([Schema.String, Schema.Null]), password_hibp_enabled: Schema.Union([Schema.Boolean, Schema.Null]), password_min_length: Schema.Union([ @@ -3284,13 +3385,16 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), password_required_characters: Schema.Union([ - Schema.Literals([ - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", - "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", - "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\\\:\"|<>?,./`~", - "", + Schema.Union([ + Schema.Literals([ + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", + "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", + "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\\\:\"|<>?,./`~", + "", + ]), + Schema.Null, ]), - Schema.Null, + OpenString, ]), rate_limit_anonymous_users: Schema.Union([ Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) @@ -3397,8 +3501,8 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ security_sb_forwarded_for_enabled: Schema.Union([Schema.Boolean, Schema.Null]), security_captcha_enabled: Schema.Union([Schema.Boolean, Schema.Null]), security_captcha_provider: Schema.Union([ - Schema.Literals(["turnstile", "hcaptcha"]), - Schema.Null, + Schema.Union([Schema.Literals(["turnstile", "hcaptcha"]), Schema.Null]), + OpenString, ]), security_captcha_secret: Schema.Union([Schema.String, Schema.Null]), security_manual_linking_enabled: Schema.Union([Schema.Boolean, Schema.Null]), @@ -3472,8 +3576,11 @@ export const V1GetAuthServiceConfigOutput = Schema.Struct({ }), ), sms_provider: Schema.Union([ - Schema.Literals(["messagebird", "textlocal", "twilio", "twilio_verify", "vonage"]), - Schema.Null, + Schema.Union([ + Schema.Literals(["messagebird", "textlocal", "twilio", "twilio_verify", "vonage"]), + Schema.Null, + ]), + OpenString, ]), sms_template: Schema.Union([Schema.String, Schema.Null]), sms_test_otp: Schema.Union([Schema.String, Schema.Null]), @@ -3576,35 +3683,40 @@ export const V1GetAvailableRegionsOutput = Schema.Struct({ recommendations: Schema.Struct({ smartGroup: Schema.Struct({ name: Schema.String, - code: Schema.Literals(["americas", "emea", "apac"]), + code: Schema.Union([Schema.Literals(["americas", "emea", "apac"]), OpenString]), type: Schema.Literal("smartGroup"), }), specific: Schema.Array( Schema.Struct({ name: Schema.String, - code: Schema.Literals([ - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", - "ap-southeast-1", - "ap-northeast-1", - "ap-northeast-2", - "ap-east-1", - "ap-southeast-2", - "eu-west-1", - "eu-west-2", - "eu-west-3", - "eu-north-1", - "eu-central-1", - "eu-central-2", - "ca-central-1", - "ap-south-1", - "sa-east-1", + code: Schema.Union([ + Schema.Literals([ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ap-southeast-1", + "ap-northeast-1", + "ap-northeast-2", + "ap-east-1", + "ap-southeast-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-north-1", + "eu-central-1", + "eu-central-2", + "ca-central-1", + "ap-south-1", + "sa-east-1", + ]), + OpenString, ]), type: Schema.Literal("specific"), - provider: Schema.Literals(["AWS", "AWS_K8S", "AWS_NIMBUS"]), - status: Schema.optionalKey(Schema.Literals(["capacity", "other"])), + provider: Schema.Union([Schema.Literals(["AWS", "AWS_K8S", "AWS_NIMBUS"]), OpenString]), + status: Schema.optionalKey( + Schema.Union([Schema.Literals(["capacity", "other"]), OpenString]), + ), }), ), }), @@ -3612,36 +3724,41 @@ export const V1GetAvailableRegionsOutput = Schema.Struct({ smartGroup: Schema.Array( Schema.Struct({ name: Schema.String, - code: Schema.Literals(["americas", "emea", "apac"]), + code: Schema.Union([Schema.Literals(["americas", "emea", "apac"]), OpenString]), type: Schema.Literal("smartGroup"), }), ), specific: Schema.Array( Schema.Struct({ name: Schema.String, - code: Schema.Literals([ - "us-east-1", - "us-east-2", - "us-west-1", - "us-west-2", - "ap-southeast-1", - "ap-northeast-1", - "ap-northeast-2", - "ap-east-1", - "ap-southeast-2", - "eu-west-1", - "eu-west-2", - "eu-west-3", - "eu-north-1", - "eu-central-1", - "eu-central-2", - "ca-central-1", - "ap-south-1", - "sa-east-1", + code: Schema.Union([ + Schema.Literals([ + "us-east-1", + "us-east-2", + "us-west-1", + "us-west-2", + "ap-southeast-1", + "ap-northeast-1", + "ap-northeast-2", + "ap-east-1", + "ap-southeast-2", + "eu-west-1", + "eu-west-2", + "eu-west-3", + "eu-north-1", + "eu-central-1", + "eu-central-2", + "ca-central-1", + "ap-south-1", + "sa-east-1", + ]), + OpenString, ]), type: Schema.Literal("specific"), - provider: Schema.Literals(["AWS", "AWS_K8S", "AWS_NIMBUS"]), - status: Schema.optionalKey(Schema.Literals(["capacity", "other"])), + provider: Schema.Union([Schema.Literals(["AWS", "AWS_K8S", "AWS_NIMBUS"]), OpenString]), + status: Schema.optionalKey( + Schema.Union([Schema.Literals(["capacity", "other"]), OpenString]), + ), }), ), }), @@ -3813,12 +3930,15 @@ export const V1GetHostnameConfigInput = Schema.Struct({ }); export const V1GetHostnameConfigOutput = Schema.Struct({ status: Schema.optionalKey( - Schema.Literals([ - "1_not_started", - "2_initiated", - "3_challenge_verified", - "4_origin_setup_completed", - "5_services_reconfigured", + Schema.Union([ + Schema.Literals([ + "1_not_started", + "2_initiated", + "3_challenge_verified", + "4_origin_setup_completed", + "5_services_reconfigured", + ]), + OpenString, ]), ), custom_hostname: Schema.optionalKey(Schema.String), @@ -3949,16 +4069,19 @@ export const V1GetJitAccessConfigInput = Schema.Struct({ export const V1GetJitAccessConfigOutput = Schema.Union( [ Schema.Struct({ - state: Schema.Literals(["enabled", "disabled"]), + state: Schema.Union([Schema.Literals(["enabled", "disabled"]), OpenString]), appliedSuccessfully: Schema.optionalKey(Schema.Boolean), }), Schema.Struct({ state: Schema.Literal("unavailable"), - unavailableReason: Schema.Literals([ - "platform_unsupported", - "postgres_upgrade_required", - "ssl_enforcement_required", - "temporarily_unavailable", + unavailableReason: Schema.Union([ + Schema.Literals([ + "platform_unsupported", + "postgres_upgrade_required", + "ssl_enforcement_required", + "temporarily_unavailable", + ]), + OpenString, ]), }), ], @@ -3989,8 +4112,11 @@ export const V1GetLegacySigningKeyOutput = Schema.Struct({ "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", }), ), - algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), - status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + algorithm: Schema.Union([Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), OpenString]), + status: Schema.Union([ + Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + OpenString, + ]), public_jwk: Schema.Json.annotate({ expected: "JSON value" }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), @@ -4009,7 +4135,7 @@ export const V1GetNetworkRestrictionsInput = Schema.Struct({ ), }); export const V1GetNetworkRestrictionsOutput = Schema.Struct({ - entitlement: Schema.Literals(["disallowed", "allowed"]), + entitlement: Schema.Union([Schema.Literals(["disallowed", "allowed"]), OpenString]), config: Schema.Struct({ dbAllowedCidrs: Schema.optionalKey(Schema.Array(Schema.String)), dbAllowedCidrsV6: Schema.optionalKey(Schema.Array(Schema.String)), @@ -4026,7 +4152,7 @@ export const V1GetNetworkRestrictionsOutput = Schema.Struct({ "Populated when a new config has been received, but not registered as successfully applied to a project.", }), ), - status: Schema.Literals(["stored", "applied"]), + status: Schema.Union([Schema.Literals(["stored", "applied"]), OpenString]), updated_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), applied_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), }); @@ -4041,76 +4167,79 @@ export const V1GetOrganizationEntitlementsOutput = Schema.Struct({ entitlements: Schema.Array( Schema.Struct({ feature: Schema.Struct({ - key: Schema.Literals([ - "instances.compute_update_available_sizes", - "instances.read_replicas", - "instances.disk_modifications", - "instances.high_availability", - "instances.orioledb", - "replication.etl", - "storage.max_file_size", - "storage.max_file_size.configurable", - "storage.image_transformations", - "storage.vector_buckets", - "storage.iceberg_catalog", - "storage.purge_cache", - "security.audit_logs_days", - "security.questionnaire", - "security.soc2_report", - "security.iso27001_certificate", - "security.private_link", - "security.enforce_mfa", - "log.retention_days", - "custom_domain", - "vanity_subdomain", - "ipv4", - "pitr.available_variants", - "log_drains", - "audit_log_drains", - "branching_limit", - "branching_persistent", - "auth.mfa_phone", - "auth.mfa_web_authn", - "auth.mfa_enhanced_security", - "auth.hooks", - "auth.platform.sso", - "auth.custom_jwt_template", - "auth.saml_2", - "auth.user_sessions", - "auth.leaked_password_protection", - "auth.advanced_auth_settings", - "auth.performance_settings", - "auth.password_hibp", - "auth.custom_oauth.max_providers", - "backup.retention_days", - "backup.restore_to_new_project", - "backup.schedule", - "function.max_count", - "function.size_limit_mb", - "realtime.max_concurrent_users", - "realtime.max_events_per_second", - "realtime.max_joins_per_second", - "realtime.max_channels_per_client", - "realtime.max_bytes_per_second", - "realtime.max_presence_events_per_second", - "realtime.max_payload_size_in_kb", - "project_scoped_roles", - "security.member_roles", - "project_pausing", - "project_cloning", - "project_restore_after_expiry", - "assistant.advance_model", - "integrations.github_connections", - "integrations.github_push_webhooks_limit", - "dedicated_pooler", - "observability.dashboard_advanced_metrics", - "api.members.invitations", - "api.members.roles", + key: Schema.Union([ + Schema.Literals([ + "instances.compute_update_available_sizes", + "instances.read_replicas", + "instances.disk_modifications", + "instances.high_availability", + "instances.orioledb", + "replication.etl", + "storage.max_file_size", + "storage.max_file_size.configurable", + "storage.image_transformations", + "storage.vector_buckets", + "storage.iceberg_catalog", + "storage.purge_cache", + "security.audit_logs_days", + "security.questionnaire", + "security.soc2_report", + "security.iso27001_certificate", + "security.private_link", + "security.enforce_mfa", + "log.retention_days", + "custom_domain", + "vanity_subdomain", + "ipv4", + "pitr.available_variants", + "log_drains", + "audit_log_drains", + "branching_limit", + "branching_persistent", + "auth.mfa_phone", + "auth.mfa_web_authn", + "auth.mfa_enhanced_security", + "auth.hooks", + "auth.platform.sso", + "auth.custom_jwt_template", + "auth.saml_2", + "auth.user_sessions", + "auth.leaked_password_protection", + "auth.advanced_auth_settings", + "auth.performance_settings", + "auth.password_hibp", + "auth.custom_oauth.max_providers", + "backup.retention_days", + "backup.restore_to_new_project", + "backup.schedule", + "function.max_count", + "function.size_limit_mb", + "realtime.max_concurrent_users", + "realtime.max_events_per_second", + "realtime.max_joins_per_second", + "realtime.max_channels_per_client", + "realtime.max_bytes_per_second", + "realtime.max_presence_events_per_second", + "realtime.max_payload_size_in_kb", + "project_scoped_roles", + "security.member_roles", + "project_pausing", + "project_cloning", + "project_restore_after_expiry", + "assistant.advance_model", + "integrations.github_connections", + "integrations.github_push_webhooks_limit", + "dedicated_pooler", + "observability.dashboard_advanced_metrics", + "api.members.invitations", + "api.members.roles", + ]), + OpenString, ]), - type: Schema.Literals(["boolean", "numeric", "set"]), + type: Schema.Union([Schema.Literals(["boolean", "numeric", "set"]), OpenString]), }), hasAccess: Schema.Boolean, - type: Schema.Literals(["boolean", "numeric", "set"]), + type: Schema.Union([Schema.Literals(["boolean", "numeric", "set"]), OpenString]), config: Schema.Union([ Schema.Struct({ enabled: Schema.Boolean }), Schema.Struct({ @@ -4145,10 +4274,16 @@ export const V1GetOrganizationProjectClaimOutput = Schema.Struct({ limit: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), }), ), - source_subscription_plan: Schema.Literals(["free", "pro", "team", "enterprise", "platform"]), - target_subscription_plan: Schema.Union([ + source_subscription_plan: Schema.Union([ Schema.Literals(["free", "pro", "team", "enterprise", "platform"]), - Schema.Null, + OpenString, + ]), + target_subscription_plan: Schema.Union([ + Schema.Union([ + Schema.Literals(["free", "pro", "team", "enterprise", "platform"]), + Schema.Null, + ]), + OpenString, ]), }), expires_at: Schema.String, @@ -4181,54 +4316,59 @@ export const V1GetPerformanceAdvisorsInput = Schema.Struct({ export const V1GetPerformanceAdvisorsOutput = Schema.Struct({ lints: Schema.Array( Schema.Struct({ - name: Schema.Literals([ - "unindexed_foreign_keys", - "auth_users_exposed", - "auth_rls_initplan", - "no_primary_key", - "unused_index", - "multiple_permissive_policies", - "policy_exists_rls_disabled", - "rls_enabled_no_policy", - "duplicate_index", - "security_definer_view", - "function_search_path_mutable", - "rls_disabled_in_public", - "extension_in_public", - "rls_references_user_metadata", - "materialized_view_in_api", - "foreign_table_in_api", - "unsupported_reg_types", - "auth_otp_long_expiry", - "auth_otp_short_length", - "ssl_not_enforced", - "log_connections_not_enabled", - "network_restrictions_not_set", - "password_requirements_min_length", - "pitr_not_enabled", - "auth_leaked_password_protection", - "auth_insufficient_mfa_options", - "auth_password_policy_missing", - "leaked_service_key", - "no_backup_admin", - "vulnerable_postgres_version", - "db_not_reachable", - "db_connection_failing", - "db_connection_limit_reached", - "instance_telemetry_lost", - "instance_db_down", - "instance_alert_firing", - "log_data_api_error_rate_high", - "log_auth_error_rate_high", - "log_storage_error_rate_high", - "log_edge_function_error_rate_high", - "project_not_active", - "advisor_check_unavailable", + name: Schema.Union([ + Schema.Literals([ + "unindexed_foreign_keys", + "auth_users_exposed", + "auth_rls_initplan", + "no_primary_key", + "unused_index", + "multiple_permissive_policies", + "policy_exists_rls_disabled", + "rls_enabled_no_policy", + "duplicate_index", + "security_definer_view", + "function_search_path_mutable", + "rls_disabled_in_public", + "extension_in_public", + "rls_references_user_metadata", + "materialized_view_in_api", + "foreign_table_in_api", + "unsupported_reg_types", + "auth_otp_long_expiry", + "auth_otp_short_length", + "ssl_not_enforced", + "log_connections_not_enabled", + "network_restrictions_not_set", + "password_requirements_min_length", + "pitr_not_enabled", + "auth_leaked_password_protection", + "auth_insufficient_mfa_options", + "auth_password_policy_missing", + "leaked_service_key", + "no_backup_admin", + "vulnerable_postgres_version", + "db_not_reachable", + "db_connection_failing", + "db_connection_limit_reached", + "instance_telemetry_lost", + "instance_db_down", + "instance_alert_firing", + "log_data_api_error_rate_high", + "log_auth_error_rate_high", + "log_storage_error_rate_high", + "log_edge_function_error_rate_high", + "project_not_active", + "advisor_check_unavailable", + ]), + OpenString, ]), title: Schema.String, - level: Schema.Literals(["ERROR", "WARN", "INFO"]), + level: Schema.Union([Schema.Literals(["ERROR", "WARN", "INFO"]), OpenString]), facing: Schema.Literal("EXTERNAL"), - categories: Schema.Array(Schema.Literals(["PERFORMANCE", "SECURITY", "HEALTH"])), + categories: Schema.Array( + Schema.Union([Schema.Literals(["PERFORMANCE", "SECURITY", "HEALTH"]), OpenString]), + ), description: Schema.String, detail: Schema.String, remediation: Schema.String, @@ -4238,16 +4378,19 @@ export const V1GetPerformanceAdvisorsOutput = Schema.Struct({ name: Schema.optionalKey(Schema.String), entity: Schema.optionalKey(Schema.String), type: Schema.optionalKey( - Schema.Literals([ - "table", - "view", - "materialized view", - "foreign table", - "auth", - "function", - "extension", - "compliance", - "health", + Schema.Union([ + Schema.Literals([ + "table", + "view", + "materialized view", + "foreign table", + "auth", + "function", + "extension", + "compliance", + "health", + ]), + OpenString, ]), ), fkey_name: Schema.optionalKey(Schema.String), @@ -4510,7 +4653,9 @@ export const V1GetPostgresConfigOutput = Schema.Struct({ }), ), ), - session_replication_role: Schema.optionalKey(Schema.Literals(["origin", "replica", "local"])), + session_replication_role: Schema.optionalKey( + Schema.Union([Schema.Literals(["origin", "replica", "local"]), OpenString]), + ), shared_buffers: Schema.optionalKey(Schema.String), statement_timeout: Schema.optionalKey( Schema.String.annotate({ description: "Default unit: ms" }).check( @@ -4554,19 +4699,21 @@ export const V1GetPostgresUpgradeEligibilityInput = Schema.Struct({ export const V1GetPostgresUpgradeEligibilityOutput = Schema.Struct({ eligible: Schema.Boolean, current_app_version: Schema.String, - current_app_version_release_channel: Schema.Literals([ - "internal", - "alpha", - "beta", - "ga", - "withdrawn", - "preview", + current_app_version_release_channel: Schema.Union([ + Schema.Literals(["internal", "alpha", "beta", "ga", "withdrawn", "preview"]), + OpenString, ]), latest_app_version: Schema.String, target_upgrade_versions: Schema.Array( Schema.Struct({ - postgres_version: Schema.Literals(["13", "14", "15", "17", "17-oriole"]), - release_channel: Schema.Literals(["internal", "alpha", "beta", "ga", "withdrawn", "preview"]), + postgres_version: Schema.Union([ + Schema.Literals(["13", "14", "15", "17", "17-oriole"]), + OpenString, + ]), + release_channel: Schema.Union([ + Schema.Literals(["internal", "alpha", "beta", "ga", "withdrawn", "preview"]), + OpenString, + ]), app_version: Schema.String, }), ), @@ -4660,31 +4807,37 @@ export const V1GetPostgresUpgradeStatusOutput = Schema.Struct({ latest_status_at: Schema.String, target_version: Schema.String, error: Schema.optionalKey( - Schema.Literals([ - "1_upgraded_instance_launch_failed", - "2_volume_detachchment_from_upgraded_instance_failed", - "3_volume_attachment_to_original_instance_failed", - "4_data_upgrade_initiation_failed", - "5_data_upgrade_completion_failed", - "6_volume_detachchment_from_original_instance_failed", - "7_volume_attachment_to_upgraded_instance_failed", - "8_upgrade_completion_failed", - "9_post_physical_backup_failed", + Schema.Union([ + Schema.Literals([ + "1_upgraded_instance_launch_failed", + "2_volume_detachchment_from_upgraded_instance_failed", + "3_volume_attachment_to_original_instance_failed", + "4_data_upgrade_initiation_failed", + "5_data_upgrade_completion_failed", + "6_volume_detachchment_from_original_instance_failed", + "7_volume_attachment_to_upgraded_instance_failed", + "8_upgrade_completion_failed", + "9_post_physical_backup_failed", + ]), + OpenString, ]), ), progress: Schema.optionalKey( - Schema.Literals([ - "0_requested", - "1_started", - "2_launched_upgraded_instance", - "3_detached_volume_from_upgraded_instance", - "4_attached_volume_to_original_instance", - "5_initiated_data_upgrade", - "6_completed_data_upgrade", - "7_detached_volume_from_original_instance", - "8_attached_volume_to_upgraded_instance", - "9_completed_upgrade", - "10_completed_post_physical_backup", + Schema.Union([ + Schema.Literals([ + "0_requested", + "1_started", + "2_launched_upgraded_instance", + "3_detached_volume_from_upgraded_instance", + "4_attached_volume_to_original_instance", + "5_initiated_data_upgrade", + "6_completed_data_upgrade", + "7_detached_volume_from_original_instance", + "8_attached_volume_to_upgraded_instance", + "9_completed_upgrade", + "10_completed_post_physical_backup", + ]), + OpenString, ]), ), status: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), @@ -4799,22 +4952,25 @@ export const V1GetProjectOutput = Schema.Struct({ name: Schema.String.annotate({ description: "Name of your project" }), region: Schema.String.annotate({ description: "Region of your project" }), created_at: Schema.String.annotate({ description: "Creation timestamp" }), - status: Schema.Literals([ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING", + status: Schema.Union([ + Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]), + OpenString, ]), database: Schema.Struct({ host: Schema.String.annotate({ description: "Database host" }), @@ -4852,7 +5008,10 @@ export const V1GetProjectApiKeyOutput = Schema.Struct({ api_key: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), type: Schema.optionalKey( - Schema.Union([Schema.Literals(["legacy", "publishable", "secret"]), Schema.Null]), + Schema.Union([ + Schema.Union([Schema.Literals(["legacy", "publishable", "secret"]), Schema.Null]), + OpenString, + ]), ), prefix: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), name: Schema.String, @@ -5106,7 +5265,9 @@ export const V1GetProjectPgbouncerConfigOutput = Schema.Struct({ }), ), ), - pool_mode: Schema.optionalKey(Schema.Literals(["transaction", "session", "statement"])), + pool_mode: Schema.optionalKey( + Schema.Union([Schema.Literals(["transaction", "session", "statement"]), OpenString]), + ), connection_string: Schema.optionalKey(Schema.String), server_idle_timeout: Schema.optionalKey( Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) @@ -5197,8 +5358,11 @@ export const V1GetProjectSigningKeyOutput = Schema.Struct({ "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", }), ), - algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), - status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + algorithm: Schema.Union([Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), OpenString]), + status: Schema.Union([ + Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + OpenString, + ]), public_jwk: Schema.Json.annotate({ expected: "JSON value" }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), @@ -5230,8 +5394,11 @@ export const V1GetProjectSigningKeysOutput = Schema.Struct({ "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", }), ), - algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), - status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + algorithm: Schema.Union([Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), OpenString]), + status: Schema.Union([ + Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + OpenString, + ]), public_jwk: Schema.Json.annotate({ expected: "JSON value" }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), @@ -5591,7 +5758,10 @@ export const V1GetRestorePointInput = Schema.Struct({ }); export const V1GetRestorePointOutput = Schema.Struct({ name: Schema.String, - status: Schema.Literals(["AVAILABLE", "PENDING", "REMOVED", "FAILED"]), + status: Schema.Union([ + Schema.Literals(["AVAILABLE", "PENDING", "REMOVED", "FAILED"]), + OpenString, + ]), completed_on: Schema.Union([Schema.String.annotate({ format: "date-time" }), Schema.Null]), }); export const V1GetSecurityAdvisorsInput = Schema.Struct({ @@ -5611,54 +5781,59 @@ export const V1GetSecurityAdvisorsInput = Schema.Struct({ export const V1GetSecurityAdvisorsOutput = Schema.Struct({ lints: Schema.Array( Schema.Struct({ - name: Schema.Literals([ - "unindexed_foreign_keys", - "auth_users_exposed", - "auth_rls_initplan", - "no_primary_key", - "unused_index", - "multiple_permissive_policies", - "policy_exists_rls_disabled", - "rls_enabled_no_policy", - "duplicate_index", - "security_definer_view", - "function_search_path_mutable", - "rls_disabled_in_public", - "extension_in_public", - "rls_references_user_metadata", - "materialized_view_in_api", - "foreign_table_in_api", - "unsupported_reg_types", - "auth_otp_long_expiry", - "auth_otp_short_length", - "ssl_not_enforced", - "log_connections_not_enabled", - "network_restrictions_not_set", - "password_requirements_min_length", - "pitr_not_enabled", - "auth_leaked_password_protection", - "auth_insufficient_mfa_options", - "auth_password_policy_missing", - "leaked_service_key", - "no_backup_admin", - "vulnerable_postgres_version", - "db_not_reachable", - "db_connection_failing", - "db_connection_limit_reached", - "instance_telemetry_lost", - "instance_db_down", - "instance_alert_firing", - "log_data_api_error_rate_high", - "log_auth_error_rate_high", - "log_storage_error_rate_high", - "log_edge_function_error_rate_high", - "project_not_active", - "advisor_check_unavailable", + name: Schema.Union([ + Schema.Literals([ + "unindexed_foreign_keys", + "auth_users_exposed", + "auth_rls_initplan", + "no_primary_key", + "unused_index", + "multiple_permissive_policies", + "policy_exists_rls_disabled", + "rls_enabled_no_policy", + "duplicate_index", + "security_definer_view", + "function_search_path_mutable", + "rls_disabled_in_public", + "extension_in_public", + "rls_references_user_metadata", + "materialized_view_in_api", + "foreign_table_in_api", + "unsupported_reg_types", + "auth_otp_long_expiry", + "auth_otp_short_length", + "ssl_not_enforced", + "log_connections_not_enabled", + "network_restrictions_not_set", + "password_requirements_min_length", + "pitr_not_enabled", + "auth_leaked_password_protection", + "auth_insufficient_mfa_options", + "auth_password_policy_missing", + "leaked_service_key", + "no_backup_admin", + "vulnerable_postgres_version", + "db_not_reachable", + "db_connection_failing", + "db_connection_limit_reached", + "instance_telemetry_lost", + "instance_db_down", + "instance_alert_firing", + "log_data_api_error_rate_high", + "log_auth_error_rate_high", + "log_storage_error_rate_high", + "log_edge_function_error_rate_high", + "project_not_active", + "advisor_check_unavailable", + ]), + OpenString, ]), title: Schema.String, - level: Schema.Literals(["ERROR", "WARN", "INFO"]), + level: Schema.Union([Schema.Literals(["ERROR", "WARN", "INFO"]), OpenString]), facing: Schema.Literal("EXTERNAL"), - categories: Schema.Array(Schema.Literals(["PERFORMANCE", "SECURITY", "HEALTH"])), + categories: Schema.Array( + Schema.Union([Schema.Literals(["PERFORMANCE", "SECURITY", "HEALTH"]), OpenString]), + ), description: Schema.String, detail: Schema.String, remediation: Schema.String, @@ -5668,16 +5843,19 @@ export const V1GetSecurityAdvisorsOutput = Schema.Struct({ name: Schema.optionalKey(Schema.String), entity: Schema.optionalKey(Schema.String), type: Schema.optionalKey( - Schema.Literals([ - "table", - "view", - "materialized view", - "foreign table", - "auth", - "function", - "extension", - "compliance", - "health", + Schema.Union([ + Schema.Literals([ + "table", + "view", + "materialized view", + "foreign table", + "auth", + "function", + "extension", + "compliance", + "health", + ]), + OpenString, ]), ), fkey_name: Schema.optionalKey(Schema.String), @@ -5852,7 +6030,9 @@ export const V1GetStorageConfigOutput = Schema.Struct({ iceberg_catalog: Schema.Boolean, object_versioning: Schema.Boolean, }), - external: Schema.Struct({ upstreamTarget: Schema.Literals(["main", "canary"]) }), + external: Schema.Struct({ + upstreamTarget: Schema.Union([Schema.Literals(["main", "canary"]), OpenString]), + }), migrationVersion: Schema.Union([Schema.String, Schema.Null]), }); export const V1GetVanitySubdomainConfigInput = Schema.Struct({ @@ -5869,7 +6049,7 @@ export const V1GetVanitySubdomainConfigInput = Schema.Struct({ ), }); export const V1GetVanitySubdomainConfigOutput = Schema.Struct({ - status: Schema.Literals(["not-used", "custom-domain-used", "active"]), + status: Schema.Union([Schema.Literals(["not-used", "custom-domain-used", "active"]), OpenString]), custom_domain: Schema.optionalKey( Schema.String.check( Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), @@ -6053,23 +6233,21 @@ export const V1ListActionRunsOutput = Schema.Array( branch_id: Schema.String, run_steps: Schema.Array( Schema.Struct({ - name: Schema.Literals([ - "clone", - "pull", - "health", - "configure", - "migrate", - "seed", - "deploy", + name: Schema.Union([ + Schema.Literals(["clone", "pull", "health", "configure", "migrate", "seed", "deploy"]), + OpenString, ]), - status: Schema.Literals([ - "CREATED", - "DEAD", - "EXITED", - "PAUSED", - "REMOVING", - "RESTARTING", - "RUNNING", + status: Schema.Union([ + Schema.Literals([ + "CREATED", + "DEAD", + "EXITED", + "PAUSED", + "REMOVING", + "RESTARTING", + "RUNNING", + ]), + OpenString, ]), created_at: Schema.String, updated_at: Schema.String, @@ -6116,13 +6294,9 @@ export const V1ListAllBackupsOutput = Schema.Struct({ }), ), is_physical_backup: Schema.Boolean, - status: Schema.Literals([ - "COMPLETED", - "FAILED", - "PENDING", - "REMOVED", - "ARCHIVED", - "CANCELLED", + status: Schema.Union([ + Schema.Literals(["COMPLETED", "FAILED", "PENDING", "REMOVED", "ARCHIVED", "CANCELLED"]), + OpenString, ]), inserted_at: Schema.String, }), @@ -6280,21 +6454,27 @@ export const V1ListAllSnippetsOutput = Schema.Struct({ inserted_at: Schema.String, updated_at: Schema.String, type: Schema.Literal("sql"), - visibility: Schema.Literals(["user", "project", "org", "public"]), + visibility: Schema.Union([Schema.Literals(["user", "project", "org", "public"]), OpenString]), name: Schema.String, description: Schema.Union([Schema.String, Schema.Null]), project: Schema.Struct({ id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), name: Schema.String, }), - owner: Schema.Struct({ - id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), - username: Schema.String, - }), - updated_by: Schema.Struct({ - id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), - username: Schema.String, - }), + owner: Schema.Union([ + Schema.Struct({ + id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + username: Schema.String, + }), + Schema.Null, + ]), + updated_by: Schema.Union([ + Schema.Struct({ + id: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), + username: Schema.String, + }), + Schema.Null, + ]), favorite: Schema.Boolean, }), ), @@ -6338,11 +6518,14 @@ export const V1ListAllSsoProviderOutput = Schema.Struct({ }), ), name_id_format: Schema.optionalKey( - Schema.Literals([ - "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + Schema.Union([ + Schema.Literals([ + "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + ]), + OpenString, ]), ), }), @@ -6378,8 +6561,14 @@ export const V1ListAvailableRestoreVersionsOutput = Schema.Struct({ available_versions: Schema.Array( Schema.Struct({ version: Schema.String, - release_channel: Schema.Literals(["internal", "alpha", "beta", "ga", "withdrawn", "preview"]), - postgres_engine: Schema.Literals(["13", "14", "15", "17", "17-oriole"]), + release_channel: Schema.Union([ + Schema.Literals(["internal", "alpha", "beta", "ga", "withdrawn", "preview"]), + OpenString, + ]), + postgres_engine: Schema.Union([ + Schema.Literals(["13", "14", "15", "17", "17-oriole"]), + OpenString, + ]), }), ), }); @@ -6568,40 +6757,46 @@ export const V1ListProjectAddonsInput = Schema.Struct({ export const V1ListProjectAddonsOutput = Schema.Struct({ selected_addons: Schema.Array( Schema.Struct({ - type: Schema.Literals([ - "custom_domain", - "compute_instance", - "pitr", - "ipv4", - "auth_mfa_phone", - "auth_mfa_web_authn", - "log_drain", - "etl_pipeline", - ]), - variant: Schema.Struct({ - id: Schema.Union([ - Schema.Literals([ - "ci_micro", - "ci_small", - "ci_medium", - "ci_large", - "ci_xlarge", - "ci_2xlarge", - "ci_4xlarge", - "ci_8xlarge", - "ci_12xlarge", - "ci_16xlarge", - "ci_24xlarge", - "ci_24xlarge_optimized_cpu", - "ci_24xlarge_optimized_memory", - "ci_24xlarge_high_memory", - "ci_48xlarge", - "ci_48xlarge_optimized_cpu", - "ci_48xlarge_optimized_memory", - "ci_48xlarge_high_memory", + type: Schema.Union([ + Schema.Literals([ + "custom_domain", + "compute_instance", + "pitr", + "ipv4", + "auth_mfa_phone", + "auth_mfa_web_authn", + "log_drain", + "etl_pipeline", + ]), + OpenString, + ]), + variant: Schema.Struct({ + id: Schema.Union([ + Schema.Union([ + Schema.Literals([ + "ci_micro", + "ci_small", + "ci_medium", + "ci_large", + "ci_xlarge", + "ci_2xlarge", + "ci_4xlarge", + "ci_8xlarge", + "ci_12xlarge", + "ci_16xlarge", + "ci_24xlarge", + "ci_24xlarge_optimized_cpu", + "ci_24xlarge_optimized_memory", + "ci_24xlarge_high_memory", + "ci_48xlarge", + "ci_48xlarge_optimized_cpu", + "ci_48xlarge_optimized_memory", + "ci_48xlarge_high_memory", + ]), + OpenString, ]), Schema.Literal("cd_default"), - Schema.Literals(["pitr_7", "pitr_14", "pitr_28"]), + Schema.Union([Schema.Literals(["pitr_7", "pitr_14", "pitr_28"]), OpenString]), Schema.Literal("ipv4_default"), Schema.Literal("auth_mfa_phone_default"), Schema.Literal("auth_mfa_web_authn_default"), @@ -6611,8 +6806,8 @@ export const V1ListProjectAddonsOutput = Schema.Struct({ name: Schema.String, price: Schema.Struct({ description: Schema.String, - type: Schema.Literals(["fixed", "usage"]), - interval: Schema.Literals(["monthly", "hourly"]), + type: Schema.Union([Schema.Literals(["fixed", "usage"]), OpenString]), + interval: Schema.Union([Schema.Literals(["monthly", "hourly"]), OpenString]), amount: Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), }), meta: Schema.optionalKey(JsonValue_Output), @@ -6621,42 +6816,48 @@ export const V1ListProjectAddonsOutput = Schema.Struct({ ), available_addons: Schema.Array( Schema.Struct({ - type: Schema.Literals([ - "custom_domain", - "compute_instance", - "pitr", - "ipv4", - "auth_mfa_phone", - "auth_mfa_web_authn", - "log_drain", - "etl_pipeline", + type: Schema.Union([ + Schema.Literals([ + "custom_domain", + "compute_instance", + "pitr", + "ipv4", + "auth_mfa_phone", + "auth_mfa_web_authn", + "log_drain", + "etl_pipeline", + ]), + OpenString, ]), name: Schema.String, variants: Schema.Array( Schema.Struct({ id: Schema.Union([ - Schema.Literals([ - "ci_micro", - "ci_small", - "ci_medium", - "ci_large", - "ci_xlarge", - "ci_2xlarge", - "ci_4xlarge", - "ci_8xlarge", - "ci_12xlarge", - "ci_16xlarge", - "ci_24xlarge", - "ci_24xlarge_optimized_cpu", - "ci_24xlarge_optimized_memory", - "ci_24xlarge_high_memory", - "ci_48xlarge", - "ci_48xlarge_optimized_cpu", - "ci_48xlarge_optimized_memory", - "ci_48xlarge_high_memory", + Schema.Union([ + Schema.Literals([ + "ci_micro", + "ci_small", + "ci_medium", + "ci_large", + "ci_xlarge", + "ci_2xlarge", + "ci_4xlarge", + "ci_8xlarge", + "ci_12xlarge", + "ci_16xlarge", + "ci_24xlarge", + "ci_24xlarge_optimized_cpu", + "ci_24xlarge_optimized_memory", + "ci_24xlarge_high_memory", + "ci_48xlarge", + "ci_48xlarge_optimized_cpu", + "ci_48xlarge_optimized_memory", + "ci_48xlarge_high_memory", + ]), + OpenString, ]), Schema.Literal("cd_default"), - Schema.Literals(["pitr_7", "pitr_14", "pitr_28"]), + Schema.Union([Schema.Literals(["pitr_7", "pitr_14", "pitr_28"]), OpenString]), Schema.Literal("ipv4_default"), Schema.Literal("auth_mfa_phone_default"), Schema.Literal("auth_mfa_web_authn_default"), @@ -6666,8 +6867,8 @@ export const V1ListProjectAddonsOutput = Schema.Struct({ name: Schema.String, price: Schema.Struct({ description: Schema.String, - type: Schema.Literals(["fixed", "usage"]), - interval: Schema.Literals(["monthly", "hourly"]), + type: Schema.Union([Schema.Literals(["fixed", "usage"]), OpenString]), + interval: Schema.Union([Schema.Literals(["monthly", "hourly"]), OpenString]), amount: Schema.Number.check( Schema.isFinite().annotate({ expected: "a finite number" }), ), @@ -6861,10 +7062,15 @@ export const V1PatchNetworkRestrictionsInput = Schema.Struct({ ), }); export const V1PatchNetworkRestrictionsOutput = Schema.Struct({ - entitlement: Schema.Literals(["disallowed", "allowed"]), + entitlement: Schema.Union([Schema.Literals(["disallowed", "allowed"]), OpenString]), config: Schema.Struct({ dbAllowedCidrs: Schema.optionalKey( - Schema.Array(Schema.Struct({ address: Schema.String, type: Schema.Literals(["v4", "v6"]) })), + Schema.Array( + Schema.Struct({ + address: Schema.String, + type: Schema.Union([Schema.Literals(["v4", "v6"]), OpenString]), + }), + ), ), }).annotate({ description: @@ -6874,7 +7080,10 @@ export const V1PatchNetworkRestrictionsOutput = Schema.Struct({ Schema.Struct({ dbAllowedCidrs: Schema.optionalKey( Schema.Array( - Schema.Struct({ address: Schema.String, type: Schema.Literals(["v4", "v6"]) }), + Schema.Struct({ + address: Schema.String, + type: Schema.Union([Schema.Literals(["v4", "v6"]), OpenString]), + }), ), ), }).annotate({ @@ -6884,7 +7093,7 @@ export const V1PatchNetworkRestrictionsOutput = Schema.Struct({ ), updated_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), applied_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), - status: Schema.Literals(["stored", "applied"]), + status: Schema.Union([Schema.Literals(["stored", "applied"]), OpenString]), }); export const V1PauseAProjectInput = Schema.Struct({ ref: Schema.String.check( @@ -7036,8 +7245,11 @@ export const V1RemoveProjectSigningKeyOutput = Schema.Struct({ "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", }), ), - algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), - status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + algorithm: Schema.Union([Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), OpenString]), + status: Schema.Union([ + Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + OpenString, + ]), public_jwk: Schema.Json.annotate({ expected: "JSON value" }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), @@ -7394,16 +7606,19 @@ export const V1UpdateABranchConfigOutput = Schema.Struct({ }).check(Schema.isFinite().annotate({ expected: "a finite number" })), ), persistent: Schema.Boolean, - status: Schema.Literals([ - "CREATING_PROJECT", - "RUNNING_MIGRATIONS", - "MIGRATIONS_PASSED", - "MIGRATIONS_FAILED", - "FUNCTIONS_DEPLOYED", - "FUNCTIONS_FAILED", - ]).annotate({ - description: "This field is deprecated. List action runs to get branch status instead.", - }), + status: Schema.Union([ + Schema.Literals([ + "CREATING_PROJECT", + "RUNNING_MIGRATIONS", + "MIGRATIONS_PASSED", + "MIGRATIONS_FAILED", + "FUNCTIONS_DEPLOYED", + "FUNCTIONS_FAILED", + ]).annotate({ + description: "This field is deprecated. List action runs to get branch status instead.", + }), + OpenString, + ]), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), review_requested_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), @@ -7411,22 +7626,25 @@ export const V1UpdateABranchConfigOutput = Schema.Struct({ notify_url: Schema.optionalKey(Schema.String.annotate({ format: "uri" })), deletion_scheduled_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), preview_project_status: Schema.optionalKey( - Schema.Literals([ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING", + Schema.Union([ + Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]), + OpenString, ]), ), }); @@ -7466,7 +7684,7 @@ export const V1UpdateAFunctionOutput = Schema.Struct({ id: Schema.String, slug: Schema.String, name: Schema.String, - status: Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), + status: Schema.Union([Schema.Literals(["ACTIVE", "REMOVED", "THROTTLED"]), OpenString]), version: Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) .check( Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ @@ -7505,7 +7723,7 @@ export const V1UpdateAFunctionOutput = Schema.Struct({ verify_jwt: Schema.optionalKey(Schema.Boolean), import_map: Schema.optionalKey(Schema.Boolean), entrypoint_path: Schema.optionalKey(Schema.String), - import_map_path: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), + import_map_path: Schema.optionalKey(Schema.String), ezbr_sha256: Schema.optionalKey(Schema.String), }); export const V1UpdateAProjectInput = Schema.Struct({ @@ -7612,11 +7830,14 @@ export const V1UpdateASsoProviderOutput = Schema.Struct({ }), ), name_id_format: Schema.optionalKey( - Schema.Literals([ - "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", - "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", - "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", - "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + Schema.Union([ + Schema.Literals([ + "urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified", + "urn:oasis:names:tc:SAML:2.0:nameid-format:transient", + "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress", + "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent", + ]), + OpenString, ]), ), }), @@ -8452,7 +8673,10 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ ), Schema.Null, ]), - db_max_pool_size_unit: Schema.Union([Schema.Literals(["connections", "percent"]), Schema.Null]), + db_max_pool_size_unit: Schema.Union([ + Schema.Union([Schema.Literals(["connections", "percent"]), Schema.Null]), + OpenString, + ]), disable_signup: Schema.Union([Schema.Boolean, Schema.Null]), external_anonymous_users_enabled: Schema.Union([Schema.Boolean, Schema.Null]), external_apple_additional_client_ids: Schema.Union([Schema.String, Schema.Null]), @@ -8708,7 +8932,7 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), nimbus_oauth_client_id: Schema.Union([Schema.String, Schema.Null]), - nimbus_oauth_email_optional: Schema.optionalKey(Schema.Union([Schema.Boolean, Schema.Null])), + nimbus_oauth_email_optional: Schema.Union([Schema.Boolean, Schema.Null]), nimbus_oauth_client_secret: Schema.Union([Schema.String, Schema.Null]), password_hibp_enabled: Schema.Union([Schema.Boolean, Schema.Null]), password_min_length: Schema.Union([ @@ -8726,13 +8950,16 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ Schema.Null, ]), password_required_characters: Schema.Union([ - Schema.Literals([ - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", - "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", - "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\\\:\"|<>?,./`~", - "", + Schema.Union([ + Schema.Literals([ + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", + "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789", + "abcdefghijklmnopqrstuvwxyz:ABCDEFGHIJKLMNOPQRSTUVWXYZ:0123456789:!@#$%^&*()_+-=[]{};'\\\\:\"|<>?,./`~", + "", + ]), + Schema.Null, ]), - Schema.Null, + OpenString, ]), rate_limit_anonymous_users: Schema.Union([ Schema.Number.check(Schema.isInt().annotate({ expected: "an integer" })) @@ -8839,8 +9066,8 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ security_sb_forwarded_for_enabled: Schema.Union([Schema.Boolean, Schema.Null]), security_captcha_enabled: Schema.Union([Schema.Boolean, Schema.Null]), security_captcha_provider: Schema.Union([ - Schema.Literals(["turnstile", "hcaptcha"]), - Schema.Null, + Schema.Union([Schema.Literals(["turnstile", "hcaptcha"]), Schema.Null]), + OpenString, ]), security_captcha_secret: Schema.Union([Schema.String, Schema.Null]), security_manual_linking_enabled: Schema.Union([Schema.Boolean, Schema.Null]), @@ -8914,8 +9141,11 @@ export const V1UpdateAuthServiceConfigOutput = Schema.Struct({ }), ), sms_provider: Schema.Union([ - Schema.Literals(["messagebird", "textlocal", "twilio", "twilio_verify", "vonage"]), - Schema.Null, + Schema.Union([ + Schema.Literals(["messagebird", "textlocal", "twilio", "twilio_verify", "vonage"]), + Schema.Null, + ]), + OpenString, ]), sms_template: Schema.Union([Schema.String, Schema.Null]), sms_test_otp: Schema.Union([Schema.String, Schema.Null]), @@ -9063,12 +9293,15 @@ export const V1UpdateHostnameConfigInput = Schema.Struct({ }); export const V1UpdateHostnameConfigOutput = Schema.Struct({ status: Schema.optionalKey( - Schema.Literals([ - "1_not_started", - "2_initiated", - "3_challenge_verified", - "4_origin_setup_completed", - "5_services_reconfigured", + Schema.Union([ + Schema.Literals([ + "1_not_started", + "2_initiated", + "3_challenge_verified", + "4_origin_setup_completed", + "5_services_reconfigured", + ]), + OpenString, ]), ), custom_hostname: Schema.optionalKey(Schema.String), @@ -9262,16 +9495,19 @@ export const V1UpdateJitAccessConfigInput = Schema.Struct({ export const V1UpdateJitAccessConfigOutput = Schema.Union( [ Schema.Struct({ - state: Schema.Literals(["enabled", "disabled"]), + state: Schema.Union([Schema.Literals(["enabled", "disabled"]), OpenString]), appliedSuccessfully: Schema.optionalKey(Schema.Boolean), }), Schema.Struct({ state: Schema.Literal("unavailable"), - unavailableReason: Schema.Literals([ - "platform_unsupported", - "postgres_upgrade_required", - "ssl_enforcement_required", - "temporarily_unavailable", + unavailableReason: Schema.Union([ + Schema.Literals([ + "platform_unsupported", + "postgres_upgrade_required", + "ssl_enforcement_required", + "temporarily_unavailable", + ]), + OpenString, ]), }), ], @@ -9293,7 +9529,7 @@ export const V1UpdateNetworkRestrictionsInput = Schema.Struct({ dbAllowedCidrsV6: Schema.optionalKey(Schema.Array(Schema.String)), }); export const V1UpdateNetworkRestrictionsOutput = Schema.Struct({ - entitlement: Schema.Literals(["disallowed", "allowed"]), + entitlement: Schema.Union([Schema.Literals(["disallowed", "allowed"]), OpenString]), config: Schema.Struct({ dbAllowedCidrs: Schema.optionalKey(Schema.Array(Schema.String)), dbAllowedCidrsV6: Schema.optionalKey(Schema.Array(Schema.String)), @@ -9310,7 +9546,7 @@ export const V1UpdateNetworkRestrictionsOutput = Schema.Struct({ "Populated when a new config has been received, but not registered as successfully applied to a project.", }), ), - status: Schema.Literals(["stored", "applied"]), + status: Schema.Union([Schema.Literals(["stored", "applied"]), OpenString]), updated_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), applied_at: Schema.optionalKey(Schema.String.annotate({ format: "date-time" })), }); @@ -9830,7 +10066,9 @@ export const V1UpdatePostgresConfigOutput = Schema.Struct({ }), ), ), - session_replication_role: Schema.optionalKey(Schema.Literals(["origin", "replica", "local"])), + session_replication_role: Schema.optionalKey( + Schema.Union([Schema.Literals(["origin", "replica", "local"]), OpenString]), + ), shared_buffers: Schema.optionalKey(Schema.String), statement_timeout: Schema.optionalKey( Schema.String.annotate({ description: "Default unit: ms" }).check( @@ -10008,7 +10246,10 @@ export const V1UpdateProjectApiKeyOutput = Schema.Struct({ api_key: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), id: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), type: Schema.optionalKey( - Schema.Union([Schema.Literals(["legacy", "publishable", "secret"]), Schema.Null]), + Schema.Union([ + Schema.Union([Schema.Literals(["legacy", "publishable", "secret"]), Schema.Null]), + OpenString, + ]), ), prefix: Schema.optionalKey(Schema.Union([Schema.String, Schema.Null])), name: Schema.String, @@ -10079,8 +10320,11 @@ export const V1UpdateProjectSigningKeyOutput = Schema.Struct({ "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", }), ), - algorithm: Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), - status: Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + algorithm: Schema.Union([Schema.Literals(["EdDSA", "ES256", "RS256", "HS256"]), OpenString]), + status: Schema.Union([ + Schema.Literals(["in_use", "previously_used", "revoked", "standby"]), + OpenString, + ]), public_jwk: Schema.Json.annotate({ expected: "JSON value" }), created_at: Schema.String.annotate({ format: "date-time" }), updated_at: Schema.String.annotate({ format: "date-time" }), @@ -10412,12 +10656,15 @@ export const V1VerifyDnsConfigInput = Schema.Struct({ }); export const V1VerifyDnsConfigOutput = Schema.Struct({ status: Schema.optionalKey( - Schema.Literals([ - "1_not_started", - "2_initiated", - "3_challenge_verified", - "4_origin_setup_completed", - "5_services_reconfigured", + Schema.Union([ + Schema.Literals([ + "1_not_started", + "2_initiated", + "3_challenge_verified", + "4_origin_setup_completed", + "5_services_reconfigured", + ]), + OpenString, ]), ), custom_hostname: Schema.optionalKey(Schema.String), @@ -10507,10 +10754,13 @@ export const V2AssignOrganizationMemberRoleOutput = Schema.Struct({ name: Schema.String.annotate({ description: "Role name. For project-scoped assignments this is the base role name.", }), - scope: Schema.Literals(["organization", "project"]).annotate({ - description: - "Whether this role applies org-wide or is scoped to specific projects for the user.", - }), + scope: Schema.Union([ + Schema.Literals(["organization", "project"]).annotate({ + description: + "Whether this role applies org-wide or is scoped to specific projects for the user.", + }), + OpenString, + ]), projects: Schema.Array(Schema.Struct({ ref: Schema.String, name: Schema.String })).annotate({ description: "Project refs this role is scoped to. Empty array for org-level roles.", }), @@ -10824,7 +11074,7 @@ export const V2CreateLogDrainOutput = Schema.Struct({ config: Schema.Union([ Schema.Struct({ url: Schema.optionalKey(Schema.String), - http: Schema.optionalKey(Schema.Literals(["http1", "http2"])), + http: Schema.optionalKey(Schema.Union([Schema.Literals(["http1", "http2"]), OpenString])), gzip: Schema.optionalKey(Schema.Boolean), headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }).annotate({ title: "webhook" }), @@ -10897,19 +11147,22 @@ export const V2CreateLogDrainOutput = Schema.Struct({ headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }).annotate({ title: "otlp" }), ]), - backend_type: Schema.Literals([ - "postgres", - "bigquery", - "clickhouse", - "webhook", - "datadog", - "loki", - "sentry", - "s3", - "axiom", - "last9", - "otlp", - "syslog", + backend_type: Schema.Union([ + Schema.Literals([ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog", + ]), + OpenString, ]), }), }), @@ -11182,10 +11435,12 @@ export const V2CreateNotebookOutput = Schema.Struct({ }), ), title: Schema.optionalKey(Schema.String), - view: Schema.optionalKey(Schema.Literals(["table", "chart"])), + view: Schema.optionalKey( + Schema.Union([Schema.Literals(["table", "chart"]), OpenString]), + ), chart: Schema.optionalKey( Schema.Struct({ - type: Schema.Literals(["bar", "line"]), + type: Schema.Union([Schema.Literals(["bar", "line"]), OpenString]), x_column: Schema.String.annotate({ description: "Result column used for the x axis.", }), @@ -11196,7 +11451,7 @@ export const V2CreateNotebookOutput = Schema.Struct({ }), }), ), - scale: Schema.Literals(["linear", "log"]), + scale: Schema.Union([Schema.Literals(["linear", "log"]), OpenString]), cumulative: Schema.Boolean, show_labels: Schema.Boolean, }).annotate({ @@ -11219,7 +11474,10 @@ export const V2CreateNotebookOutput = Schema.Struct({ }), Schema.Struct({ type: Schema.Literal("relative"), - unit: Schema.Literals(["minute", "hour", "day", "week", "month", "year"]), + unit: Schema.Union([ + Schema.Literals(["minute", "hour", "day", "week", "month", "year"]), + OpenString, + ]), amount: Schema.Number.check( Schema.isInt().annotate({ expected: "an integer" }), ) @@ -11236,10 +11494,12 @@ export const V2CreateNotebookOutput = Schema.Struct({ { mode: "oneOf" }, ), title: Schema.optionalKey(Schema.String), - view: Schema.optionalKey(Schema.Literals(["table", "chart"])), + view: Schema.optionalKey( + Schema.Union([Schema.Literals(["table", "chart"]), OpenString]), + ), chart: Schema.optionalKey( Schema.Struct({ - type: Schema.Literals(["bar", "line"]), + type: Schema.Union([Schema.Literals(["bar", "line"]), OpenString]), x_column: Schema.String.annotate({ description: "Result column used for the x axis.", }), @@ -11250,7 +11510,7 @@ export const V2CreateNotebookOutput = Schema.Struct({ }), }), ), - scale: Schema.Literals(["linear", "log"]), + scale: Schema.Union([Schema.Literals(["linear", "log"]), OpenString]), cumulative: Schema.Boolean, show_labels: Schema.Boolean, }).annotate({ @@ -11323,47 +11583,50 @@ export const V2CreateOrganizationInvitationsOutput = Schema.Struct({ error: Schema.optionalKey( Schema.Struct({ id: Schema.optionalKey(Schema.String), - code: Schema.Literals([ - "bad_request", - "forbidden", - "internal_server_error", - "not_found", - "payment_required", - "rate_limit_exceeded", - "request_timeout", - "request_too_large", - "unauthorized", - "validation_failed", - "agentic_credential_not_found", - "branch_admin_required", - "compute_build_context_too_large", - "compute_build_in_progress", - "compute_deploy_rate_limit_exceeded", - "compute_instance_limit_exceeded", - "compute_instance_not_found", - "compute_not_enabled", - "compute_request_rejected", - "compute_unavailable", - "notebook_cell_duplicate", - "notebook_cell_unknown", - "notebook_disabled", - "notebook_not_found", - "notebook_sort_invalid", - "organization_invitation_email_delivery_failed", - "organization_invitations_partially_failed", - "organization_role_name_invalid", - "organization_role_not_entitled", - "pagination_cursor_invalid", - "project_branch_resync_unsupported", - "project_not_found", - "project_privatelink_disabled", - "project_transfer_disabled", - "delivery_not_found", - "endpoint_disabled", - "endpoint_event_type_not_subscribed", - "endpoint_not_found", - "endpoint_url_unresolvable", - "webhooks_access_disabled", + code: Schema.Union([ + Schema.Literals([ + "bad_request", + "forbidden", + "internal_server_error", + "not_found", + "payment_required", + "rate_limit_exceeded", + "request_timeout", + "request_too_large", + "unauthorized", + "validation_failed", + "agentic_credential_not_found", + "branch_admin_required", + "compute_build_context_too_large", + "compute_build_in_progress", + "compute_deploy_rate_limit_exceeded", + "compute_instance_limit_exceeded", + "compute_instance_not_found", + "compute_not_enabled", + "compute_request_rejected", + "compute_unavailable", + "notebook_cell_duplicate", + "notebook_cell_unknown", + "notebook_disabled", + "notebook_not_found", + "notebook_sort_invalid", + "organization_invitation_email_delivery_failed", + "organization_invitations_partially_failed", + "organization_role_name_invalid", + "organization_role_not_entitled", + "pagination_cursor_invalid", + "project_branch_resync_unsupported", + "project_not_found", + "project_privatelink_disabled", + "project_transfer_disabled", + "delivery_not_found", + "endpoint_disabled", + "endpoint_event_type_not_subscribed", + "endpoint_not_found", + "endpoint_url_unresolvable", + "webhooks_access_disabled", + ]), + OpenString, ]), message: Schema.String, description: Schema.optionalKey(Schema.String), @@ -11523,17 +11786,20 @@ export const V2CreatePrivateLinkAssociationOutput = Schema.Struct({ account_name: Schema.optionalKey( Schema.String.annotate({ description: "Human-readable name for the AWS account." }), ), - status: Schema.Literals([ - "CREATING", - "READY", - "ASSOCIATION_REQUEST_EXPIRED", - "ASSOCIATION_ACCEPTED", - "CREATION_FAILED", - "DELETING", - ]).annotate({ - description: - "\n - `CREATING`: The PrivateLink resources and the Association are in the process of being created. The PrivateLink Share cannot be accepted yet.\n - `READY`: The PrivateLink resources have been created and the PrivateLink Share can be accepted for the duration of 12h after sharing. See `shared_at`.\n - `ASSOCIATION_REQUEST_EXPIRED`: The PrivateLink Share has not been accepted within the 12h time limit. This association can now be deleted.\n - `ASSOCIATION_ACCEPTED`: The PrivateLink Share was successfully accepted.\n - `CREATION_FAILED`: The PrivateLink resources failed to create. This likely means something went wrong on Supabase's and and support should be contacted.\n - `DELETING`: The PrivateLink resources and the Association are in the process of being deleted. The PrivateLink Share cannot be accepted yet.\n", - }), + status: Schema.Union([ + Schema.Literals([ + "CREATING", + "READY", + "ASSOCIATION_REQUEST_EXPIRED", + "ASSOCIATION_ACCEPTED", + "CREATION_FAILED", + "DELETING", + ]).annotate({ + description: + "\n - `CREATING`: The PrivateLink resources and the Association are in the process of being created. The PrivateLink Share cannot be accepted yet.\n - `READY`: The PrivateLink resources have been created and the PrivateLink Share can be accepted for the duration of 12h after sharing. See `shared_at`.\n - `ASSOCIATION_REQUEST_EXPIRED`: The PrivateLink Share has not been accepted within the 12h time limit. This association can now be deleted.\n - `ASSOCIATION_ACCEPTED`: The PrivateLink Share was successfully accepted.\n - `CREATION_FAILED`: The PrivateLink resources failed to create. This likely means something went wrong on Supabase's and and support should be contacted.\n - `DELETING`: The PrivateLink resources and the Association are in the process of being deleted. The PrivateLink Share cannot be accepted yet.\n", + }), + OpenString, + ]), shared_at: Schema.Union([ Schema.String.annotate({ description: @@ -11542,10 +11808,13 @@ export const V2CreatePrivateLinkAssociationOutput = Schema.Struct({ }), Schema.Null, ]), - database_type: Schema.Literals(["PRIMARY", "READ_REPLICA"]).annotate({ - description: - "Whether this PrivateLink share targets the primary database or a read replica.", - }), + database_type: Schema.Union([ + Schema.Literals(["PRIMARY", "READ_REPLICA"]).annotate({ + description: + "Whether this PrivateLink share targets the primary database or a read replica.", + }), + OpenString, + ]), database_identifier: Schema.String.annotate({ description: "Identifier of the database this PrivateLink share targets - the project ref for the primary, or the read replica identifier.", @@ -11789,7 +12058,7 @@ export const V2DeployAComputeInstanceOutput = Schema.Struct({ }), ), }), - build_state: Schema.Literals(["building", "active", "failed"]), + build_state: Schema.Union([Schema.Literals(["building", "active", "failed"]), OpenString]), secret_generation: Schema.String, state_reason: Schema.optionalKey(Schema.String), image_version: Schema.optionalKey(Schema.String), @@ -11885,7 +12154,7 @@ export const V2GetAComputeInstanceOutput = Schema.Struct({ }), ), }), - build_state: Schema.Literals(["building", "active", "failed"]), + build_state: Schema.Union([Schema.Literals(["building", "active", "failed"]), OpenString]), secret_generation: Schema.String, state_reason: Schema.optionalKey(Schema.String), image_version: Schema.optionalKey(Schema.String), @@ -12021,10 +12290,12 @@ export const V2GetNotebookOutput = Schema.Struct({ }), ), title: Schema.optionalKey(Schema.String), - view: Schema.optionalKey(Schema.Literals(["table", "chart"])), + view: Schema.optionalKey( + Schema.Union([Schema.Literals(["table", "chart"]), OpenString]), + ), chart: Schema.optionalKey( Schema.Struct({ - type: Schema.Literals(["bar", "line"]), + type: Schema.Union([Schema.Literals(["bar", "line"]), OpenString]), x_column: Schema.String.annotate({ description: "Result column used for the x axis.", }), @@ -12035,7 +12306,7 @@ export const V2GetNotebookOutput = Schema.Struct({ }), }), ), - scale: Schema.Literals(["linear", "log"]), + scale: Schema.Union([Schema.Literals(["linear", "log"]), OpenString]), cumulative: Schema.Boolean, show_labels: Schema.Boolean, }).annotate({ @@ -12058,7 +12329,10 @@ export const V2GetNotebookOutput = Schema.Struct({ }), Schema.Struct({ type: Schema.Literal("relative"), - unit: Schema.Literals(["minute", "hour", "day", "week", "month", "year"]), + unit: Schema.Union([ + Schema.Literals(["minute", "hour", "day", "week", "month", "year"]), + OpenString, + ]), amount: Schema.Number.check( Schema.isInt().annotate({ expected: "an integer" }), ) @@ -12075,10 +12349,12 @@ export const V2GetNotebookOutput = Schema.Struct({ { mode: "oneOf" }, ), title: Schema.optionalKey(Schema.String), - view: Schema.optionalKey(Schema.Literals(["table", "chart"])), + view: Schema.optionalKey( + Schema.Union([Schema.Literals(["table", "chart"]), OpenString]), + ), chart: Schema.optionalKey( Schema.Struct({ - type: Schema.Literals(["bar", "line"]), + type: Schema.Union([Schema.Literals(["bar", "line"]), OpenString]), x_column: Schema.String.annotate({ description: "Result column used for the x axis.", }), @@ -12089,7 +12365,7 @@ export const V2GetNotebookOutput = Schema.Struct({ }), }), ), - scale: Schema.Literals(["linear", "log"]), + scale: Schema.Union([Schema.Literals(["linear", "log"]), OpenString]), cumulative: Schema.Boolean, show_labels: Schema.Boolean, }).annotate({ @@ -12143,12 +12419,18 @@ export const V2GetProjectConfigOutput = Schema.Struct({ description: "Whether the database rejects plaintext connections", }), network_restrictions: Schema.Struct({ - entitlement: Schema.Literals(["disallowed", "allowed"]), - status: Schema.Literals(["stored", "applied"]).annotate({ - description: "Whether the allowlist below is applied to the project or only stored.", - }), + entitlement: Schema.Union([Schema.Literals(["disallowed", "allowed"]), OpenString]), + status: Schema.Union([ + Schema.Literals(["stored", "applied"]).annotate({ + description: "Whether the allowlist below is applied to the project or only stored.", + }), + OpenString, + ]), allowed_cidrs: Schema.Array( - Schema.Struct({ address: Schema.String, type: Schema.Literals(["v4", "v6"]) }), + Schema.Struct({ + address: Schema.String, + type: Schema.Union([Schema.Literals(["v4", "v6"]), OpenString]), + }), ), updated_at: Schema.optionalKey(Schema.String), applied_at: Schema.optionalKey(Schema.String), @@ -12335,7 +12617,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ ), ), session_replication_role: Schema.optionalKey( - Schema.Literals(["origin", "replica", "local"]), + Schema.Union([Schema.Literals(["origin", "replica", "local"]), OpenString]), ), shared_buffers: Schema.optionalKey(Schema.String), statement_timeout: Schema.optionalKey( @@ -12405,7 +12687,10 @@ export const V2GetProjectConfigOutput = Schema.Struct({ }), }), pooler: Schema.Struct({ - pool_mode: Schema.Literals(["transaction", "session", "statement"]), + pool_mode: Schema.Union([ + Schema.Literals(["transaction", "session", "statement"]), + OpenString, + ]), ignore_startup_parameters: Schema.String, server_idle_timeout: Schema.Number.check( Schema.isInt().annotate({ expected: "an integer" }), @@ -12748,7 +13033,7 @@ export const V2GetProjectConfigOutput = Schema.Struct({ iceberg_catalog: Schema.Boolean, object_versioning: Schema.Boolean, }), - upstream_target: Schema.Literals(["main", "canary"]), + upstream_target: Schema.Union([Schema.Literals(["main", "canary"]), OpenString]), migration_version: Schema.Union([Schema.String, Schema.Null]), }).annotate({ description: @@ -12792,7 +13077,7 @@ export const V2ListAllComputeInstancesOutput = Schema.Struct({ }), ), }), - build_state: Schema.Literals(["building", "active", "failed"]), + build_state: Schema.Union([Schema.Literals(["building", "active", "failed"]), OpenString]), secret_generation: Schema.String, state_reason: Schema.optionalKey(Schema.String), image_version: Schema.optionalKey(Schema.String), @@ -12874,7 +13159,9 @@ export const V2ListLogDrainsOutput = Schema.Struct({ config: Schema.Union([ Schema.Struct({ url: Schema.optionalKey(Schema.String), - http: Schema.optionalKey(Schema.Literals(["http1", "http2"])), + http: Schema.optionalKey( + Schema.Union([Schema.Literals(["http1", "http2"]), OpenString]), + ), gzip: Schema.optionalKey(Schema.Boolean), headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }).annotate({ title: "webhook" }), @@ -12947,19 +13234,22 @@ export const V2ListLogDrainsOutput = Schema.Struct({ headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }).annotate({ title: "otlp" }), ]), - backend_type: Schema.Literals([ - "postgres", - "bigquery", - "clickhouse", - "webhook", - "datadog", - "loki", - "sentry", - "s3", - "axiom", - "last9", - "otlp", - "syslog", + backend_type: Schema.Union([ + Schema.Literals([ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog", + ]), + OpenString, ]), }), }), @@ -13336,10 +13626,13 @@ export const V2ListOrganizationMembersOutput = Schema.Struct({ name: Schema.String.annotate({ description: "Role name. For project-scoped roles this is the base role name.", }), - scope: Schema.Literals(["organization", "project"]).annotate({ - description: - "Whether this role applies org-wide or is scoped to specific projects for the user.", - }), + scope: Schema.Union([ + Schema.Literals(["organization", "project"]).annotate({ + description: + "Whether this role applies org-wide or is scoped to specific projects for the user.", + }), + OpenString, + ]), projects: Schema.Array( Schema.Struct({ ref: Schema.String, name: Schema.String }), ).annotate({ @@ -13436,23 +13729,26 @@ export const V2ListOrganizationProjectsOutput = Schema.Struct({ ), attributes: Schema.Struct({ name: Schema.String.annotate({ description: "Project name" }), - status: Schema.Literals([ - "INACTIVE", - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "UNKNOWN", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UPGRADING", - "PAUSING", - "RESTORE_FAILED", - "RESTARTING", - "PAUSE_FAILED", - "RESIZING", - ]).annotate({ description: "Project status" }), + status: Schema.Union([ + Schema.Literals([ + "INACTIVE", + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "UNKNOWN", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UPGRADING", + "PAUSING", + "RESTORE_FAILED", + "RESTARTING", + "PAUSE_FAILED", + "RESIZING", + ]).annotate({ description: "Project status" }), + OpenString, + ]), cloud_provider: Schema.String.annotate({ description: "Cloud provider hosting the project", }), @@ -13463,49 +13759,57 @@ export const V2ListOrganizationProjectsOutput = Schema.Struct({ cloud_provider: Schema.String, identifier: Schema.String, region: Schema.Union([Schema.String, Schema.Null]), - status: Schema.Literals([ - "ACTIVE_HEALTHY", - "ACTIVE_UNHEALTHY", - "COMING_UP", - "GOING_DOWN", - "INIT_FAILED", - "REMOVED", - "RESTORING", - "UNKNOWN", - "INIT_READ_REPLICA", - "INIT_READ_REPLICA_FAILED", - "RESTARTING", - "RESIZING", + status: Schema.Union([ + Schema.Literals([ + "ACTIVE_HEALTHY", + "ACTIVE_UNHEALTHY", + "COMING_UP", + "GOING_DOWN", + "INIT_FAILED", + "REMOVED", + "RESTORING", + "UNKNOWN", + "INIT_READ_REPLICA", + "INIT_READ_REPLICA_FAILED", + "RESTARTING", + "RESIZING", + ]), + OpenString, ]), - type: Schema.Literals(["PRIMARY", "READ_REPLICA"]), + type: Schema.Union([Schema.Literals(["PRIMARY", "READ_REPLICA"]), OpenString]), infra_compute_size: Schema.optionalKey( - Schema.Literals([ - "pico", - "nano", - "micro", - "small", - "medium", - "large", - "xlarge", - "2xlarge", - "4xlarge", - "8xlarge", - "12xlarge", - "16xlarge", - "24xlarge", - "24xlarge_optimized_memory", - "24xlarge_optimized_cpu", - "24xlarge_high_memory", - "48xlarge", - "48xlarge_optimized_memory", - "48xlarge_optimized_cpu", - "48xlarge_high_memory", + Schema.Union([ + Schema.Literals([ + "pico", + "nano", + "micro", + "small", + "medium", + "large", + "xlarge", + "2xlarge", + "4xlarge", + "8xlarge", + "12xlarge", + "16xlarge", + "24xlarge", + "24xlarge_optimized_memory", + "24xlarge_optimized_cpu", + "24xlarge_high_memory", + "48xlarge", + "48xlarge_optimized_memory", + "48xlarge_optimized_cpu", + "48xlarge_high_memory", + ]), + OpenString, ]), ), disk_volume_size_gb: Schema.optionalKey( Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), - disk_type: Schema.optionalKey(Schema.Literals(["gp3", "io2"])), + disk_type: Schema.optionalKey( + Schema.Union([Schema.Literals(["gp3", "io2"]), OpenString]), + ), disk_throughput_mbps: Schema.optionalKey( Schema.Number.check(Schema.isFinite().annotate({ expected: "a finite number" })), ), @@ -13595,17 +13899,20 @@ export const V2ListPrivateLinkAssociationsOutput = Schema.Struct({ account_name: Schema.optionalKey( Schema.String.annotate({ description: "Human-readable name for the AWS account." }), ), - status: Schema.Literals([ - "CREATING", - "READY", - "ASSOCIATION_REQUEST_EXPIRED", - "ASSOCIATION_ACCEPTED", - "CREATION_FAILED", - "DELETING", - ]).annotate({ - description: - "\n - `CREATING`: The PrivateLink resources and the Association are in the process of being created. The PrivateLink Share cannot be accepted yet.\n - `READY`: The PrivateLink resources have been created and the PrivateLink Share can be accepted for the duration of 12h after sharing. See `shared_at`.\n - `ASSOCIATION_REQUEST_EXPIRED`: The PrivateLink Share has not been accepted within the 12h time limit. This association can now be deleted.\n - `ASSOCIATION_ACCEPTED`: The PrivateLink Share was successfully accepted.\n - `CREATION_FAILED`: The PrivateLink resources failed to create. This likely means something went wrong on Supabase's and and support should be contacted.\n - `DELETING`: The PrivateLink resources and the Association are in the process of being deleted. The PrivateLink Share cannot be accepted yet.\n", - }), + status: Schema.Union([ + Schema.Literals([ + "CREATING", + "READY", + "ASSOCIATION_REQUEST_EXPIRED", + "ASSOCIATION_ACCEPTED", + "CREATION_FAILED", + "DELETING", + ]).annotate({ + description: + "\n - `CREATING`: The PrivateLink resources and the Association are in the process of being created. The PrivateLink Share cannot be accepted yet.\n - `READY`: The PrivateLink resources have been created and the PrivateLink Share can be accepted for the duration of 12h after sharing. See `shared_at`.\n - `ASSOCIATION_REQUEST_EXPIRED`: The PrivateLink Share has not been accepted within the 12h time limit. This association can now be deleted.\n - `ASSOCIATION_ACCEPTED`: The PrivateLink Share was successfully accepted.\n - `CREATION_FAILED`: The PrivateLink resources failed to create. This likely means something went wrong on Supabase's and and support should be contacted.\n - `DELETING`: The PrivateLink resources and the Association are in the process of being deleted. The PrivateLink Share cannot be accepted yet.\n", + }), + OpenString, + ]), shared_at: Schema.Union([ Schema.String.annotate({ description: @@ -13614,10 +13921,13 @@ export const V2ListPrivateLinkAssociationsOutput = Schema.Struct({ }), Schema.Null, ]), - database_type: Schema.Literals(["PRIMARY", "READ_REPLICA"]).annotate({ - description: - "Whether this PrivateLink share targets the primary database or a read replica.", - }), + database_type: Schema.Union([ + Schema.Literals(["PRIMARY", "READ_REPLICA"]).annotate({ + description: + "Whether this PrivateLink share targets the primary database or a read replica.", + }), + OpenString, + ]), database_identifier: Schema.String.annotate({ description: "Identifier of the database this PrivateLink share targets - the project ref for the primary, or the read replica identifier.", @@ -13648,31 +13958,2877 @@ export const V2ListPrivateLinkAssociationsOutput = Schema.Struct({ }), ), }); -export const V2PreviewAProjectTransferInput = Schema.Struct({ - ref: Schema.String.check( - Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), - ) - .check( - Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), - ) - .check( - Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ - expected: "a string matching the RegExp ^[a-z]+$", +export const V2OrganizationsSlugWebhooksDeliveriesIdGetInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), +}); +export const V2OrganizationsSlugWebhooksDeliveriesIdGetOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("delivery").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of a delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of a delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + event_id: Schema.String.annotate({ + description: "Identifier of the event, which triggered the delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + status: Schema.Union([ + Schema.Literals(["pending", "success", "failure", "skipped"]).annotate({ + description: "Status of the delivery attempt.", + }), + OpenString, + ]), + response_code: Schema.Number.annotate({ + description: "HTTP status code of the response, `0` if unavailable.", + }) + .check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + response_headers: Schema.Record(Schema.String, Schema.String).annotate({ + description: "HTTP headers of the response, `{}` if unavailable.", + }), + response_body: Schema.Union([ + Schema.Union([ + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Parsed JSON representation of an HTTP body of the response, `{}` if unavailable.", + }), + Schema.String.annotate({ + description: "String representation of an HTTP body of the response.", + }), + ]), + Schema.Null, + ]), + error_code: Schema.Union([Schema.String, Schema.Null]), + error_message: Schema.Union([Schema.String, Schema.Null]), + attempt_timestamp: Schema.String.annotate({ + description: "Timestamp of the attempt.", + format: "date-time", + }), + event: Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + payload: Schema.Struct({ + organization_slug: Schema.String.annotate({ description: "Organization slug" }).check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + project_ref: Schema.Union([ + Schema.String.annotate({ description: "Project ref" }) + .check( + Schema.isMinCodePoints(20).annotate({ + expected: "a string with at least 20 code points", + }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ + expected: "a string with at most 20 code points", + }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + Schema.Null, + ]), + }).annotate({ description: "Final data sent to the consumer." }), + timestamp: Schema.String.annotate({ + description: "Timestamp of event publication.", + format: "date-time", + }), + }), + }), + }), +}); +export const V2OrganizationsSlugWebhooksDeliveriesIdRetryPostInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), +}); +export const V2OrganizationsSlugWebhooksDeliveriesIdRetryPostOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("ingress").annotate({ description: "Resource type." }), + id: Schema.String, + }), +}); +export const V2OrganizationsSlugWebhooksEndpointsDeleteInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), +}); +export const V2OrganizationsSlugWebhooksEndpointsDeleteOutput = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check( + Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), + ), + custom_headers: Schema.optionalKey( + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), + ), +}); +export const V2OrganizationsSlugWebhooksEndpointsGetInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + "page[offset]": Schema.optionalKey( + Schema.String.check( + Schema.isPattern(new RegExp("^\\d+$", "u")).annotate({ + expected: "a string matching the RegExp ^\\d+$", + }), + ), + ), + "page[limit]": Schema.optionalKey( + Schema.String.check( + Schema.isPattern(new RegExp("^\\d+$", "u")).annotate({ + expected: "a string matching the RegExp ^\\d+$", + }), + ), + ), +}); +export const V2OrganizationsSlugWebhooksEndpointsGetOutput = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check( + Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), + ), + custom_headers: Schema.optionalKey( + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), + ), + links: Schema.Struct({ + first: Schema.optionalKey( + Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the first page if available.", + }), + ), + prev: Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the previous page.", + }), + next: Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the next page.", + }), + last: Schema.optionalKey( + Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the last page if available.", + }), + ), + }), +}); +export const V2OrganizationsSlugWebhooksEndpointsIdDeleteInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), +}); +export const V2OrganizationsSlugWebhooksEndpointsIdDeleteOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + custom_headers: Schema.optionalKey( + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), +}); +export const V2OrganizationsSlugWebhooksEndpointsIdDeliveriesGetInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + "page[before]": Schema.optionalKey( + Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + ), + "page[after]": Schema.optionalKey( + Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + ), + "page[size]": Schema.optionalKey( + Schema.String.check( + Schema.isPattern(new RegExp("^\\d+$", "u")).annotate({ + expected: "a string matching the RegExp ^\\d+$", + }), + ), + ), +}); +export const V2OrganizationsSlugWebhooksEndpointsIdDeliveriesGetOutput = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("delivery").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of a delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of a delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + event_id: Schema.String.annotate({ + description: "Identifier of the event, which triggered the delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + status: Schema.Union([ + Schema.Literals(["pending", "success", "failure", "skipped"]).annotate({ + description: "Status of the delivery attempt.", + }), + OpenString, + ]), + response_code: Schema.Number.annotate({ + description: "HTTP status code of the response, `0` if unavailable.", + }) + .check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + response_headers: Schema.Record(Schema.String, Schema.String).annotate({ + description: "HTTP headers of the response, `{}` if unavailable.", + }), + response_body: Schema.Union([ + Schema.Union([ + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Parsed JSON representation of an HTTP body of the response, `{}` if unavailable.", + }), + Schema.String.annotate({ + description: "String representation of an HTTP body of the response.", + }), + ]), + Schema.Null, + ]), + error_code: Schema.Union([Schema.String, Schema.Null]), + error_message: Schema.Union([Schema.String, Schema.Null]), + attempt_timestamp: Schema.String.annotate({ + description: "Timestamp of the attempt.", + format: "date-time", + }), + event: Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + payload: Schema.Struct({ + organization_slug: Schema.String.annotate({ description: "Organization slug" }).check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + project_ref: Schema.Union([ + Schema.String.annotate({ description: "Project ref" }) + .check( + Schema.isMinCodePoints(20).annotate({ + expected: "a string with at least 20 code points", + }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ + expected: "a string with at most 20 code points", + }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + Schema.Null, + ]), + }).annotate({ description: "Final data sent to the consumer." }), + timestamp: Schema.String.annotate({ + description: "Timestamp of event publication.", + format: "date-time", + }), + }), + }), + }), + ), + links: Schema.Struct({ + first: Schema.optionalKey( + Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the first page if available.", + }), + ), + prev: Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the previous page.", + }), + next: Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the next page.", + }), + last: Schema.optionalKey( + Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the last page if available.", + }), + ), + }), +}); +export const V2OrganizationsSlugWebhooksEndpointsIdGetInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), +}); +export const V2OrganizationsSlugWebhooksEndpointsIdGetOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + custom_headers: Schema.optionalKey( + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), +}); +export const V2OrganizationsSlugWebhooksEndpointsIdPatchInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + url: Schema.optionalKey( + Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + ), + enabled: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + ), + event_types: Schema.optionalKey( + Schema.Array( + Schema.Struct({ + type: Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check( + Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), + ), + ), + custom_headers: Schema.optionalKey( + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + signing_secret: Schema.optionalKey( + Schema.String.annotate({ + description: + "Secret key to use when signing. All events will be signed according to the [Standard Webhooks](https://www.standardwebhooks.com/) specification.", + }) + .check( + Schema.isMinCodePoints(8).annotate({ + expected: "a string with at least 8 code points", + }), + ) + .check( + Schema.isMaxCodePoints(64).annotate({ + expected: "a string with at most 64 code points", + }), + ), + ), + }), + }), +}); +export const V2OrganizationsSlugWebhooksEndpointsIdPatchOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + custom_headers: Schema.optionalKey( + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), +}); +export const V2OrganizationsSlugWebhooksEndpointsIdTestPostInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + data: Schema.optionalKey( + Schema.Struct({ + type: Schema.Literal("event").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + type: Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + ]).annotate({ description: "Webhook event type." }), + }), + }), + ), +}); +export const V2OrganizationsSlugWebhooksEndpointsIdTestPostOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("ingress").annotate({ description: "Resource type." }), + id: Schema.String, + }), +}); +export const V2OrganizationsSlugWebhooksEndpointsPostInput = Schema.Struct({ + slug: Schema.String.check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + ), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + custom_headers: Schema.optionalKey( + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + signing_secret: Schema.String.annotate({ + description: + "Secret key to use when signing. All events will be signed according to the [Standard Webhooks](https://www.standardwebhooks.com/) specification.", + }) + .check( + Schema.isMinCodePoints(8).annotate({ expected: "a string with at least 8 code points" }), + ) + .check( + Schema.isMaxCodePoints(64).annotate({ expected: "a string with at most 64 code points" }), + ), + }), + }), +}); +export const V2OrganizationsSlugWebhooksEndpointsPostOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + custom_headers: Schema.optionalKey( + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), +}); +export const V2PreviewAProjectTransferInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + data: Schema.Struct({ + type: Schema.Literal("project_transfer_input").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ target_organization_slug: Schema.String }), + }), +}); +export const V2PreviewAProjectTransferOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("project_transfer_result").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + valid: Schema.Boolean, + warnings: Schema.Array(Schema.Struct({ key: Schema.String, message: Schema.String })), + errors: Schema.Array(Schema.Struct({ key: Schema.String, message: Schema.String })), + info: Schema.Array(Schema.Struct({ key: Schema.String, message: Schema.String })), + }), + }), +}); +export const V2ProjectsRefWebhooksDeliveriesIdGetInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), +}); +export const V2ProjectsRefWebhooksDeliveriesIdGetOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("delivery").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of a delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of a delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + event_id: Schema.String.annotate({ + description: "Identifier of the event, which triggered the delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + status: Schema.Union([ + Schema.Literals(["pending", "success", "failure", "skipped"]).annotate({ + description: "Status of the delivery attempt.", + }), + OpenString, + ]), + response_code: Schema.Number.annotate({ + description: "HTTP status code of the response, `0` if unavailable.", + }) + .check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + response_headers: Schema.Record(Schema.String, Schema.String).annotate({ + description: "HTTP headers of the response, `{}` if unavailable.", + }), + response_body: Schema.Union([ + Schema.Union([ + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Parsed JSON representation of an HTTP body of the response, `{}` if unavailable.", + }), + Schema.String.annotate({ + description: "String representation of an HTTP body of the response.", + }), + ]), + Schema.Null, + ]), + error_code: Schema.Union([Schema.String, Schema.Null]), + error_message: Schema.Union([Schema.String, Schema.Null]), + attempt_timestamp: Schema.String.annotate({ + description: "Timestamp of the attempt.", + format: "date-time", + }), + event: Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + payload: Schema.Struct({ + organization_slug: Schema.String.annotate({ description: "Organization slug" }).check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + project_ref: Schema.Union([ + Schema.String.annotate({ description: "Project ref" }) + .check( + Schema.isMinCodePoints(20).annotate({ + expected: "a string with at least 20 code points", + }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ + expected: "a string with at most 20 code points", + }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + Schema.Null, + ]), + }).annotate({ description: "Final data sent to the consumer." }), + timestamp: Schema.String.annotate({ + description: "Timestamp of event publication.", + format: "date-time", + }), + }), + }), + }), +}); +export const V2ProjectsRefWebhooksDeliveriesIdRetryPostInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), +}); +export const V2ProjectsRefWebhooksDeliveriesIdRetryPostOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("ingress").annotate({ description: "Resource type." }), + id: Schema.String, + }), +}); +export const V2ProjectsRefWebhooksEndpointsDeleteInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), +}); +export const V2ProjectsRefWebhooksEndpointsDeleteOutput = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check( + Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), + ), + custom_headers: Schema.optionalKey( + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), + ), +}); +export const V2ProjectsRefWebhooksEndpointsGetInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + "page[offset]": Schema.optionalKey( + Schema.String.check( + Schema.isPattern(new RegExp("^\\d+$", "u")).annotate({ + expected: "a string matching the RegExp ^\\d+$", + }), + ), + ), + "page[limit]": Schema.optionalKey( + Schema.String.check( + Schema.isPattern(new RegExp("^\\d+$", "u")).annotate({ + expected: "a string matching the RegExp ^\\d+$", + }), + ), + ), +}); +export const V2ProjectsRefWebhooksEndpointsGetOutput = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check( + Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), + ), + custom_headers: Schema.optionalKey( + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), + ), + links: Schema.Struct({ + first: Schema.optionalKey( + Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the first page if available.", + }), + ), + prev: Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the previous page.", + }), + next: Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the next page.", + }), + last: Schema.optionalKey( + Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the last page if available.", + }), + ), + }), +}); +export const V2ProjectsRefWebhooksEndpointsIdDeleteInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), +}); +export const V2ProjectsRefWebhooksEndpointsIdDeleteOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + custom_headers: Schema.optionalKey( + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), +}); +export const V2ProjectsRefWebhooksEndpointsIdDeliveriesGetInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + "page[before]": Schema.optionalKey( + Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + ), + "page[after]": Schema.optionalKey( + Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + ), + "page[size]": Schema.optionalKey( + Schema.String.check( + Schema.isPattern(new RegExp("^\\d+$", "u")).annotate({ + expected: "a string matching the RegExp ^\\d+$", + }), + ), + ), +}); +export const V2ProjectsRefWebhooksEndpointsIdDeliveriesGetOutput = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + type: Schema.Literal("delivery").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of a delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of a delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + event_id: Schema.String.annotate({ + description: "Identifier of the event, which triggered the delivery (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + status: Schema.Union([ + Schema.Literals(["pending", "success", "failure", "skipped"]).annotate({ + description: "Status of the delivery attempt.", + }), + OpenString, + ]), + response_code: Schema.Number.annotate({ + description: "HTTP status code of the response, `0` if unavailable.", + }) + .check(Schema.isInt().annotate({ expected: "an integer" })) + .check( + Schema.isGreaterThanOrEqualTo(-9007199254740991).annotate({ + expected: "a value greater than or equal to -9007199254740991", + }), + ) + .check( + Schema.isLessThanOrEqualTo(9007199254740991).annotate({ + expected: "a value less than or equal to 9007199254740991", + }), + ), + response_headers: Schema.Record(Schema.String, Schema.String).annotate({ + description: "HTTP headers of the response, `{}` if unavailable.", + }), + response_body: Schema.Union([ + Schema.Union([ + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Parsed JSON representation of an HTTP body of the response, `{}` if unavailable.", + }), + Schema.String.annotate({ + description: "String representation of an HTTP body of the response.", + }), + ]), + Schema.Null, + ]), + error_code: Schema.Union([Schema.String, Schema.Null]), + error_message: Schema.Union([Schema.String, Schema.Null]), + attempt_timestamp: Schema.String.annotate({ + description: "Timestamp of the attempt.", + format: "date-time", + }), + event: Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + payload: Schema.Struct({ + organization_slug: Schema.String.annotate({ description: "Organization slug" }).check( + Schema.isPattern(new RegExp("^[\\w-]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[\\w-]+$", + }), + ), + project_ref: Schema.Union([ + Schema.String.annotate({ description: "Project ref" }) + .check( + Schema.isMinCodePoints(20).annotate({ + expected: "a string with at least 20 code points", + }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ + expected: "a string with at most 20 code points", + }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + Schema.Null, + ]), + }).annotate({ description: "Final data sent to the consumer." }), + timestamp: Schema.String.annotate({ + description: "Timestamp of event publication.", + format: "date-time", + }), + }), + }), + }), + ), + links: Schema.Struct({ + first: Schema.optionalKey( + Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the first page if available.", + }), + ), + prev: Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the previous page.", + }), + next: Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the next page.", + }), + last: Schema.optionalKey( + Schema.Union([Schema.String, Schema.Null]).annotate({ + description: "URL path to the last page if available.", + }), + ), + }), +}); +export const V2ProjectsRefWebhooksEndpointsIdGetInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), +}); +export const V2ProjectsRefWebhooksEndpointsIdGetOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + custom_headers: Schema.optionalKey( + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), +}); +export const V2ProjectsRefWebhooksEndpointsIdPatchInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + url: Schema.optionalKey( + Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + ), + enabled: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + ), + event_types: Schema.optionalKey( + Schema.Array( + Schema.Struct({ + type: Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check( + Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" }), + ), + ), + custom_headers: Schema.optionalKey( + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + signing_secret: Schema.optionalKey( + Schema.String.annotate({ + description: + "Secret key to use when signing. All events will be signed according to the [Standard Webhooks](https://www.standardwebhooks.com/) specification.", + }) + .check( + Schema.isMinCodePoints(8).annotate({ + expected: "a string with at least 8 code points", + }), + ) + .check( + Schema.isMaxCodePoints(64).annotate({ + expected: "a string with at most 64 code points", + }), + ), + ), + }), + }), +}); +export const V2ProjectsRefWebhooksEndpointsIdPatchOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + attributes: Schema.Struct({ + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + custom_headers: Schema.optionalKey( + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), + }), + }), +}); +export const V2ProjectsRefWebhooksEndpointsIdTestPostInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + id: Schema.String.annotate({ format: "uuid" }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + data: Schema.optionalKey( + Schema.Struct({ + type: Schema.Literal("event").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + type: Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + ]).annotate({ description: "Webhook event type." }), + }), + }), + ), +}); +export const V2ProjectsRefWebhooksEndpointsIdTestPostOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("ingress").annotate({ description: "Resource type." }), + id: Schema.String, + }), +}); +export const V2ProjectsRefWebhooksEndpointsPostInput = Schema.Struct({ + ref: Schema.String.check( + Schema.isMinCodePoints(20).annotate({ expected: "a string with at least 20 code points" }), + ) + .check( + Schema.isMaxCodePoints(20).annotate({ expected: "a string with at most 20 code points" }), + ) + .check( + Schema.isPattern(new RegExp("^[a-z]+$", "u")).annotate({ + expected: "a string matching the RegExp ^[a-z]+$", + }), + ), + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + attributes: Schema.Struct({ + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.optionalKey( + Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + ), + description: Schema.optionalKey( + Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + ), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + custom_headers: Schema.optionalKey( + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + signing_secret: Schema.String.annotate({ + description: + "Secret key to use when signing. All events will be signed according to the [Standard Webhooks](https://www.standardwebhooks.com/) specification.", + }) + .check( + Schema.isMinCodePoints(8).annotate({ expected: "a string with at least 8 code points" }), + ) + .check( + Schema.isMaxCodePoints(64).annotate({ expected: "a string with at most 64 code points" }), + ), + }), + }), +}); +export const V2ProjectsRefWebhooksEndpointsPostOutput = Schema.Struct({ + data: Schema.Struct({ + type: Schema.Literal("endpoint").annotate({ description: "Resource type." }), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", }), ), - data: Schema.Struct({ - type: Schema.Literal("project_transfer_input").annotate({ description: "Resource type." }), - attributes: Schema.Struct({ target_organization_slug: Schema.String }), - }), -}); -export const V2PreviewAProjectTransferOutput = Schema.Struct({ - data: Schema.Struct({ - type: Schema.Literal("project_transfer_result").annotate({ description: "Resource type." }), attributes: Schema.Struct({ - valid: Schema.Boolean, - warnings: Schema.Array(Schema.Struct({ key: Schema.String, message: Schema.String })), - errors: Schema.Array(Schema.Struct({ key: Schema.String, message: Schema.String })), - info: Schema.Array(Schema.Struct({ key: Schema.String, message: Schema.String })), + id: Schema.String.annotate({ + description: "Identifier of an endpoint (UUID v7).", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + url: Schema.String.annotate({ + description: + "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + format: "uri", + }), + enabled: Schema.Boolean.annotate({ + description: + "Whether the endpoint is enabled or not - disabled endpoints won't emit any events.", + }), + description: Schema.Union([ + Schema.String.check( + Schema.isMaxCodePoints(512).annotate({ + expected: "a string with at most 512 code points", + }), + ), + Schema.Null, + ]).annotate({ description: "Optional description for the endpoint." }), + event_types: Schema.Array( + Schema.Struct({ + type: Schema.Union([ + Schema.Literals([ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*", + ]).annotate({ description: "Webhook event type." }), + OpenString, + ]), + }), + ) + .annotate({ + description: "List of subscribed events for which to receive the webhook event.", + }) + .check(Schema.isMinLength(1).annotate({ expected: "a value with a length of at least 1" })), + custom_headers: Schema.optionalKey( + Schema.Record(Schema.String, Schema.String).annotate({ + description: + "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + }), + ), + created_by: Schema.String.annotate({ + description: "ID of the user who created the endpoint.", + format: "uuid", + }).check( + Schema.isPattern( + new RegExp( + "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "u", + ), + ).annotate({ + expected: + "a string matching the RegExp ^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + }), + ), + created_at: Schema.String.annotate({ + description: "Timestamp of endpoint's creation.", + format: "date-time", + }), }), }), }); @@ -13755,54 +16911,59 @@ export const V2RunProjectAdvisorsOutput = Schema.Struct({ attributes: Schema.Struct({ lints: Schema.Array( Schema.Struct({ - name: Schema.Literals([ - "unindexed_foreign_keys", - "auth_users_exposed", - "auth_rls_initplan", - "no_primary_key", - "unused_index", - "multiple_permissive_policies", - "policy_exists_rls_disabled", - "rls_enabled_no_policy", - "duplicate_index", - "security_definer_view", - "function_search_path_mutable", - "rls_disabled_in_public", - "extension_in_public", - "rls_references_user_metadata", - "materialized_view_in_api", - "foreign_table_in_api", - "unsupported_reg_types", - "auth_otp_long_expiry", - "auth_otp_short_length", - "ssl_not_enforced", - "log_connections_not_enabled", - "network_restrictions_not_set", - "password_requirements_min_length", - "pitr_not_enabled", - "auth_leaked_password_protection", - "auth_insufficient_mfa_options", - "auth_password_policy_missing", - "leaked_service_key", - "no_backup_admin", - "vulnerable_postgres_version", - "db_not_reachable", - "db_connection_failing", - "db_connection_limit_reached", - "instance_telemetry_lost", - "instance_db_down", - "instance_alert_firing", - "log_data_api_error_rate_high", - "log_auth_error_rate_high", - "log_storage_error_rate_high", - "log_edge_function_error_rate_high", - "project_not_active", - "advisor_check_unavailable", + name: Schema.Union([ + Schema.Literals([ + "unindexed_foreign_keys", + "auth_users_exposed", + "auth_rls_initplan", + "no_primary_key", + "unused_index", + "multiple_permissive_policies", + "policy_exists_rls_disabled", + "rls_enabled_no_policy", + "duplicate_index", + "security_definer_view", + "function_search_path_mutable", + "rls_disabled_in_public", + "extension_in_public", + "rls_references_user_metadata", + "materialized_view_in_api", + "foreign_table_in_api", + "unsupported_reg_types", + "auth_otp_long_expiry", + "auth_otp_short_length", + "ssl_not_enforced", + "log_connections_not_enabled", + "network_restrictions_not_set", + "password_requirements_min_length", + "pitr_not_enabled", + "auth_leaked_password_protection", + "auth_insufficient_mfa_options", + "auth_password_policy_missing", + "leaked_service_key", + "no_backup_admin", + "vulnerable_postgres_version", + "db_not_reachable", + "db_connection_failing", + "db_connection_limit_reached", + "instance_telemetry_lost", + "instance_db_down", + "instance_alert_firing", + "log_data_api_error_rate_high", + "log_auth_error_rate_high", + "log_storage_error_rate_high", + "log_edge_function_error_rate_high", + "project_not_active", + "advisor_check_unavailable", + ]), + OpenString, ]), title: Schema.String, - level: Schema.Literals(["ERROR", "WARN", "INFO"]), + level: Schema.Union([Schema.Literals(["ERROR", "WARN", "INFO"]), OpenString]), facing: Schema.Literal("EXTERNAL"), - categories: Schema.Array(Schema.Literals(["PERFORMANCE", "SECURITY", "HEALTH"])), + categories: Schema.Array( + Schema.Union([Schema.Literals(["PERFORMANCE", "SECURITY", "HEALTH"]), OpenString]), + ), description: Schema.String, detail: Schema.String, remediation: Schema.String, @@ -13812,16 +16973,19 @@ export const V2RunProjectAdvisorsOutput = Schema.Struct({ name: Schema.optionalKey(Schema.String), entity: Schema.optionalKey(Schema.String), type: Schema.optionalKey( - Schema.Literals([ - "table", - "view", - "materialized view", - "foreign table", - "auth", - "function", - "extension", - "compliance", - "health", + Schema.Union([ + Schema.Literals([ + "table", + "view", + "materialized view", + "foreign table", + "auth", + "function", + "extension", + "compliance", + "health", + ]), + OpenString, ]), ), fkey_name: Schema.optionalKey(Schema.String), @@ -13992,7 +17156,7 @@ export const V2UpdateLogDrainOutput = Schema.Struct({ config: Schema.Union([ Schema.Struct({ url: Schema.optionalKey(Schema.String), - http: Schema.optionalKey(Schema.Literals(["http1", "http2"])), + http: Schema.optionalKey(Schema.Union([Schema.Literals(["http1", "http2"]), OpenString])), gzip: Schema.optionalKey(Schema.Boolean), headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }).annotate({ title: "webhook" }), @@ -14065,19 +17229,22 @@ export const V2UpdateLogDrainOutput = Schema.Struct({ headers: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)), }).annotate({ title: "otlp" }), ]), - backend_type: Schema.Literals([ - "postgres", - "bigquery", - "clickhouse", - "webhook", - "datadog", - "loki", - "sentry", - "s3", - "axiom", - "last9", - "otlp", - "syslog", + backend_type: Schema.Union([ + Schema.Literals([ + "postgres", + "bigquery", + "clickhouse", + "webhook", + "datadog", + "loki", + "sentry", + "s3", + "axiom", + "last9", + "otlp", + "syslog", + ]), + OpenString, ]), }), }), @@ -14380,10 +17547,12 @@ export const V2UpdateNotebookOutput = Schema.Struct({ }), ), title: Schema.optionalKey(Schema.String), - view: Schema.optionalKey(Schema.Literals(["table", "chart"])), + view: Schema.optionalKey( + Schema.Union([Schema.Literals(["table", "chart"]), OpenString]), + ), chart: Schema.optionalKey( Schema.Struct({ - type: Schema.Literals(["bar", "line"]), + type: Schema.Union([Schema.Literals(["bar", "line"]), OpenString]), x_column: Schema.String.annotate({ description: "Result column used for the x axis.", }), @@ -14394,7 +17563,7 @@ export const V2UpdateNotebookOutput = Schema.Struct({ }), }), ), - scale: Schema.Literals(["linear", "log"]), + scale: Schema.Union([Schema.Literals(["linear", "log"]), OpenString]), cumulative: Schema.Boolean, show_labels: Schema.Boolean, }).annotate({ @@ -14417,7 +17586,10 @@ export const V2UpdateNotebookOutput = Schema.Struct({ }), Schema.Struct({ type: Schema.Literal("relative"), - unit: Schema.Literals(["minute", "hour", "day", "week", "month", "year"]), + unit: Schema.Union([ + Schema.Literals(["minute", "hour", "day", "week", "month", "year"]), + OpenString, + ]), amount: Schema.Number.check( Schema.isInt().annotate({ expected: "an integer" }), ) @@ -14434,10 +17606,12 @@ export const V2UpdateNotebookOutput = Schema.Struct({ { mode: "oneOf" }, ), title: Schema.optionalKey(Schema.String), - view: Schema.optionalKey(Schema.Literals(["table", "chart"])), + view: Schema.optionalKey( + Schema.Union([Schema.Literals(["table", "chart"]), OpenString]), + ), chart: Schema.optionalKey( Schema.Struct({ - type: Schema.Literals(["bar", "line"]), + type: Schema.Union([Schema.Literals(["bar", "line"]), OpenString]), x_column: Schema.String.annotate({ description: "Result column used for the x axis.", }), @@ -14448,7 +17622,7 @@ export const V2UpdateNotebookOutput = Schema.Struct({ }), }), ), - scale: Schema.Literals(["linear", "log"]), + scale: Schema.Union([Schema.Literals(["linear", "log"]), OpenString]), cumulative: Schema.Boolean, show_labels: Schema.Boolean, }).annotate({ @@ -14706,7 +17880,33 @@ export const openApiOperationIdMap = { "v2-list-organization-projects": "v2ListOrganizationProjects", "v2-list-organization-roles": "v2ListOrganizationRoles", "v2-list-private-link-associations": "v2ListPrivateLinkAssociations", + "v2-organizations-slug-webhooks-deliveries-id-get": "v2OrganizationsSlugWebhooksDeliveriesIdGet", + "v2-organizations-slug-webhooks-deliveries-id-retry-post": + "v2OrganizationsSlugWebhooksDeliveriesIdRetryPost", + "v2-organizations-slug-webhooks-endpoints-delete": "v2OrganizationsSlugWebhooksEndpointsDelete", + "v2-organizations-slug-webhooks-endpoints-get": "v2OrganizationsSlugWebhooksEndpointsGet", + "v2-organizations-slug-webhooks-endpoints-id-delete": + "v2OrganizationsSlugWebhooksEndpointsIdDelete", + "v2-organizations-slug-webhooks-endpoints-id-deliveries-get": + "v2OrganizationsSlugWebhooksEndpointsIdDeliveriesGet", + "v2-organizations-slug-webhooks-endpoints-id-get": "v2OrganizationsSlugWebhooksEndpointsIdGet", + "v2-organizations-slug-webhooks-endpoints-id-patch": + "v2OrganizationsSlugWebhooksEndpointsIdPatch", + "v2-organizations-slug-webhooks-endpoints-id-test-post": + "v2OrganizationsSlugWebhooksEndpointsIdTestPost", + "v2-organizations-slug-webhooks-endpoints-post": "v2OrganizationsSlugWebhooksEndpointsPost", "v2-preview-a-project-transfer": "v2PreviewAProjectTransfer", + "v2-projects-ref-webhooks-deliveries-id-get": "v2ProjectsRefWebhooksDeliveriesIdGet", + "v2-projects-ref-webhooks-deliveries-id-retry-post": "v2ProjectsRefWebhooksDeliveriesIdRetryPost", + "v2-projects-ref-webhooks-endpoints-delete": "v2ProjectsRefWebhooksEndpointsDelete", + "v2-projects-ref-webhooks-endpoints-get": "v2ProjectsRefWebhooksEndpointsGet", + "v2-projects-ref-webhooks-endpoints-id-delete": "v2ProjectsRefWebhooksEndpointsIdDelete", + "v2-projects-ref-webhooks-endpoints-id-deliveries-get": + "v2ProjectsRefWebhooksEndpointsIdDeliveriesGet", + "v2-projects-ref-webhooks-endpoints-id-get": "v2ProjectsRefWebhooksEndpointsIdGet", + "v2-projects-ref-webhooks-endpoints-id-patch": "v2ProjectsRefWebhooksEndpointsIdPatch", + "v2-projects-ref-webhooks-endpoints-id-test-post": "v2ProjectsRefWebhooksEndpointsIdTestPost", + "v2-projects-ref-webhooks-endpoints-post": "v2ProjectsRefWebhooksEndpointsPost", "v2-run-project-advisors": "v2RunProjectAdvisors", "v2-transfer-a-project": "v2TransferAProject", "v2-update-log-drain": "v2UpdateLogDrain", @@ -17802,6 +21002,141 @@ export const operationDefinitions = { inputSchema: V2ListPrivateLinkAssociationsInput, outputSchema: V2ListPrivateLinkAssociationsOutput, }, + v2OrganizationsSlugWebhooksDeliveriesIdGet: { + id: "v2OrganizationsSlugWebhooksDeliveriesIdGet", + description: "Get details of a specific delivery attempt.", + method: "GET", + path: "/v2/organizations/{slug}/webhooks/deliveries/{id}", + pathParams: ["slug", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2OrganizationsSlugWebhooksDeliveriesIdGetInput, + outputSchema: V2OrganizationsSlugWebhooksDeliveriesIdGetOutput, + }, + v2OrganizationsSlugWebhooksDeliveriesIdRetryPost: { + id: "v2OrganizationsSlugWebhooksDeliveriesIdRetryPost", + description: + "Retry delivering the same event again.\n\nAutomatic retries are not applicable to manual retries - if the delivery fails, there won't be any automatic retries attempted.\n\nThis endpoint is heavy rate-limited to allow for 10 request within 60 seconds.", + method: "POST", + path: "/v2/organizations/{slug}/webhooks/deliveries/{id}/retry", + pathParams: ["slug", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2OrganizationsSlugWebhooksDeliveriesIdRetryPostInput, + outputSchema: V2OrganizationsSlugWebhooksDeliveriesIdRetryPostOutput, + }, + v2OrganizationsSlugWebhooksEndpointsDelete: { + id: "v2OrganizationsSlugWebhooksEndpointsDelete", + description: + "Delete all endpoints including all events and deliveries.\n\nAny in-flight webhooks will result in a no-op.", + method: "DELETE", + path: "/v2/organizations/{slug}/webhooks/endpoints", + pathParams: ["slug"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2OrganizationsSlugWebhooksEndpointsDeleteInput, + outputSchema: V2OrganizationsSlugWebhooksEndpointsDeleteOutput, + }, + v2OrganizationsSlugWebhooksEndpointsGet: { + id: "v2OrganizationsSlugWebhooksEndpointsGet", + description: "List all Webhook endpoints based on a project's ref or an organization's slug.", + method: "GET", + path: "/v2/organizations/{slug}/webhooks/endpoints", + pathParams: ["slug"], + queryParams: ["page[offset]", "page[limit]"], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2OrganizationsSlugWebhooksEndpointsGetInput, + outputSchema: V2OrganizationsSlugWebhooksEndpointsGetOutput, + }, + v2OrganizationsSlugWebhooksEndpointsIdDelete: { + id: "v2OrganizationsSlugWebhooksEndpointsIdDelete", + description: + "Delete the endpoint including all events and deliveries\n\nAny in-flight webhooks will result in a no-op.", + method: "DELETE", + path: "/v2/organizations/{slug}/webhooks/endpoints/{id}", + pathParams: ["slug", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2OrganizationsSlugWebhooksEndpointsIdDeleteInput, + outputSchema: V2OrganizationsSlugWebhooksEndpointsIdDeleteOutput, + }, + v2OrganizationsSlugWebhooksEndpointsIdDeliveriesGet: { + id: "v2OrganizationsSlugWebhooksEndpointsIdDeliveriesGet", + description: + "List all deliveries for a specific endpoint in descending order (newest first).\n\nDeliveries which has expired are no longer available and will not be listed.", + method: "GET", + path: "/v2/organizations/{slug}/webhooks/endpoints/{id}/deliveries", + pathParams: ["slug", "id"], + queryParams: ["page[before]", "page[after]", "page[size]"], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2OrganizationsSlugWebhooksEndpointsIdDeliveriesGetInput, + outputSchema: V2OrganizationsSlugWebhooksEndpointsIdDeliveriesGetOutput, + }, + v2OrganizationsSlugWebhooksEndpointsIdGet: { + id: "v2OrganizationsSlugWebhooksEndpointsIdGet", + description: "Get details of a specific endpoint.", + method: "GET", + path: "/v2/organizations/{slug}/webhooks/endpoints/{id}", + pathParams: ["slug", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2OrganizationsSlugWebhooksEndpointsIdGetInput, + outputSchema: V2OrganizationsSlugWebhooksEndpointsIdGetOutput, + }, + v2OrganizationsSlugWebhooksEndpointsIdPatch: { + id: "v2OrganizationsSlugWebhooksEndpointsIdPatch", + description: "Update endpoint's configuration.", + method: "PATCH", + path: "/v2/organizations/{slug}/webhooks/endpoints/{id}", + pathParams: ["slug", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["data"] }, + response: { kind: "json" }, + inputSchema: V2OrganizationsSlugWebhooksEndpointsIdPatchInput, + outputSchema: V2OrganizationsSlugWebhooksEndpointsIdPatchOutput, + }, + v2OrganizationsSlugWebhooksEndpointsIdTestPost: { + id: "v2OrganizationsSlugWebhooksEndpointsIdTestPost", + description: + "Ingests and schedules a test webhook event to be published out this endpoints.\n\nWhich event type to use can be specified in the request body, otherwise\nit will use any matching type the endpoint is listening for.\n\nThe event will contain `is_test: true` in it's payload.\n\nThis endpoint is heavy rate-limited to allow for 10 request within 60 seconds.", + method: "POST", + path: "/v2/organizations/{slug}/webhooks/endpoints/{id}/test", + pathParams: ["slug", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["data"] }, + response: { kind: "json" }, + inputSchema: V2OrganizationsSlugWebhooksEndpointsIdTestPostInput, + outputSchema: V2OrganizationsSlugWebhooksEndpointsIdTestPostOutput, + }, + v2OrganizationsSlugWebhooksEndpointsPost: { + id: "v2OrganizationsSlugWebhooksEndpointsPost", + description: "Create new endpoint configuration to subscribe to specific webhook events.", + method: "POST", + path: "/v2/organizations/{slug}/webhooks/endpoints", + pathParams: ["slug"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["data"] }, + response: { kind: "json" }, + inputSchema: V2OrganizationsSlugWebhooksEndpointsPostInput, + outputSchema: V2OrganizationsSlugWebhooksEndpointsPostOutput, + }, v2PreviewAProjectTransfer: { id: "v2PreviewAProjectTransfer", description: @@ -17816,6 +21151,141 @@ export const operationDefinitions = { inputSchema: V2PreviewAProjectTransferInput, outputSchema: V2PreviewAProjectTransferOutput, }, + v2ProjectsRefWebhooksDeliveriesIdGet: { + id: "v2ProjectsRefWebhooksDeliveriesIdGet", + description: "Get details of a specific delivery attempt.", + method: "GET", + path: "/v2/projects/{ref}/webhooks/deliveries/{id}", + pathParams: ["ref", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2ProjectsRefWebhooksDeliveriesIdGetInput, + outputSchema: V2ProjectsRefWebhooksDeliveriesIdGetOutput, + }, + v2ProjectsRefWebhooksDeliveriesIdRetryPost: { + id: "v2ProjectsRefWebhooksDeliveriesIdRetryPost", + description: + "Retry delivering the same event again.\n\nAutomatic retries are not applicable to manual retries - if the delivery fails, there won't be any automatic retries attempted.\n\nThis endpoint is heavy rate-limited to allow for 10 request within 60 seconds.", + method: "POST", + path: "/v2/projects/{ref}/webhooks/deliveries/{id}/retry", + pathParams: ["ref", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2ProjectsRefWebhooksDeliveriesIdRetryPostInput, + outputSchema: V2ProjectsRefWebhooksDeliveriesIdRetryPostOutput, + }, + v2ProjectsRefWebhooksEndpointsDelete: { + id: "v2ProjectsRefWebhooksEndpointsDelete", + description: + "Delete all endpoints including all events and deliveries.\n\nAny in-flight webhooks will result in a no-op.", + method: "DELETE", + path: "/v2/projects/{ref}/webhooks/endpoints", + pathParams: ["ref"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2ProjectsRefWebhooksEndpointsDeleteInput, + outputSchema: V2ProjectsRefWebhooksEndpointsDeleteOutput, + }, + v2ProjectsRefWebhooksEndpointsGet: { + id: "v2ProjectsRefWebhooksEndpointsGet", + description: "List all Webhook endpoints based on a project's ref or an organization's slug.", + method: "GET", + path: "/v2/projects/{ref}/webhooks/endpoints", + pathParams: ["ref"], + queryParams: ["page[offset]", "page[limit]"], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2ProjectsRefWebhooksEndpointsGetInput, + outputSchema: V2ProjectsRefWebhooksEndpointsGetOutput, + }, + v2ProjectsRefWebhooksEndpointsIdDelete: { + id: "v2ProjectsRefWebhooksEndpointsIdDelete", + description: + "Delete the endpoint including all events and deliveries\n\nAny in-flight webhooks will result in a no-op.", + method: "DELETE", + path: "/v2/projects/{ref}/webhooks/endpoints/{id}", + pathParams: ["ref", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2ProjectsRefWebhooksEndpointsIdDeleteInput, + outputSchema: V2ProjectsRefWebhooksEndpointsIdDeleteOutput, + }, + v2ProjectsRefWebhooksEndpointsIdDeliveriesGet: { + id: "v2ProjectsRefWebhooksEndpointsIdDeliveriesGet", + description: + "List all deliveries for a specific endpoint in descending order (newest first).\n\nDeliveries which has expired are no longer available and will not be listed.", + method: "GET", + path: "/v2/projects/{ref}/webhooks/endpoints/{id}/deliveries", + pathParams: ["ref", "id"], + queryParams: ["page[before]", "page[after]", "page[size]"], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2ProjectsRefWebhooksEndpointsIdDeliveriesGetInput, + outputSchema: V2ProjectsRefWebhooksEndpointsIdDeliveriesGetOutput, + }, + v2ProjectsRefWebhooksEndpointsIdGet: { + id: "v2ProjectsRefWebhooksEndpointsIdGet", + description: "Get details of a specific endpoint.", + method: "GET", + path: "/v2/projects/{ref}/webhooks/endpoints/{id}", + pathParams: ["ref", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "none" }, + response: { kind: "json" }, + inputSchema: V2ProjectsRefWebhooksEndpointsIdGetInput, + outputSchema: V2ProjectsRefWebhooksEndpointsIdGetOutput, + }, + v2ProjectsRefWebhooksEndpointsIdPatch: { + id: "v2ProjectsRefWebhooksEndpointsIdPatch", + description: "Update endpoint's configuration.", + method: "PATCH", + path: "/v2/projects/{ref}/webhooks/endpoints/{id}", + pathParams: ["ref", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["data"] }, + response: { kind: "json" }, + inputSchema: V2ProjectsRefWebhooksEndpointsIdPatchInput, + outputSchema: V2ProjectsRefWebhooksEndpointsIdPatchOutput, + }, + v2ProjectsRefWebhooksEndpointsIdTestPost: { + id: "v2ProjectsRefWebhooksEndpointsIdTestPost", + description: + "Ingests and schedules a test webhook event to be published out this endpoints.\n\nWhich event type to use can be specified in the request body, otherwise\nit will use any matching type the endpoint is listening for.\n\nThe event will contain `is_test: true` in it's payload.\n\nThis endpoint is heavy rate-limited to allow for 10 request within 60 seconds.", + method: "POST", + path: "/v2/projects/{ref}/webhooks/endpoints/{id}/test", + pathParams: ["ref", "id"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["data"] }, + response: { kind: "json" }, + inputSchema: V2ProjectsRefWebhooksEndpointsIdTestPostInput, + outputSchema: V2ProjectsRefWebhooksEndpointsIdTestPostOutput, + }, + v2ProjectsRefWebhooksEndpointsPost: { + id: "v2ProjectsRefWebhooksEndpointsPost", + description: "Create new endpoint configuration to subscribe to specific webhook events.", + method: "POST", + path: "/v2/projects/{ref}/webhooks/endpoints", + pathParams: ["ref"], + queryParams: [], + headerParams: [], + requestBody: { kind: "json", contentType: "application/json", fields: ["data"] }, + response: { kind: "json" }, + inputSchema: V2ProjectsRefWebhooksEndpointsPostInput, + outputSchema: V2ProjectsRefWebhooksEndpointsPostOutput, + }, v2RunProjectAdvisors: { id: "v2RunProjectAdvisors", description: "Runs the project advisors with the given names", diff --git a/packages/api/src/generated/effect-client.ts b/packages/api/src/generated/effect-client.ts index 57a9646ec9..d1f8bd2150 100644 --- a/packages/api/src/generated/effect-client.ts +++ b/packages/api/src/generated/effect-client.ts @@ -1,7 +1,6 @@ -import { Effect, Schema } from "effect"; +import { Effect } from "effect"; -import type { EffectClient } from "../internal/effect-client.ts"; -import type { SupabaseApiError } from "../internal/client.ts"; +import type { SupabaseApiError, SupabaseApiRequestOptions } from "../internal/client.ts"; import { SupabaseApiClient } from "../internal/client.ts"; import { operationDefinitions } from "./contracts.ts"; @@ -9,6 +8,7 @@ export const versionedEffectOperations = { v1: { acceptInviteExternalJitAccess: ( input: typeof operationDefinitions.v1AcceptInviteExternalJitAccess.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1AcceptInviteExternalJitAccess.outputSchema.Type, SupabaseApiError, @@ -19,10 +19,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1AcceptInviteExternalJitAccess">( operationDefinitions.v1AcceptInviteExternalJitAccess, input, + options, ); }), activateCustomHostname: ( input: typeof operationDefinitions.v1ActivateCustomHostname.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ActivateCustomHostname.outputSchema.Type, SupabaseApiError, @@ -33,10 +35,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ActivateCustomHostname">( operationDefinitions.v1ActivateCustomHostname, input, + options, ); }), activateVanitySubdomainConfig: ( input: typeof operationDefinitions.v1ActivateVanitySubdomainConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ActivateVanitySubdomainConfig.outputSchema.Type, SupabaseApiError, @@ -47,10 +51,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ActivateVanitySubdomainConfig">( operationDefinitions.v1ActivateVanitySubdomainConfig, input, + options, ); }), applyAMigration: ( input: typeof operationDefinitions.v1ApplyAMigration.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ApplyAMigration.outputSchema.Type, SupabaseApiError, @@ -61,10 +67,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ApplyAMigration">( operationDefinitions.v1ApplyAMigration, input, + options, ); }), applyProjectAddon: ( input: typeof operationDefinitions.v1ApplyProjectAddon.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ApplyProjectAddon.outputSchema.Type, SupabaseApiError, @@ -75,10 +83,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ApplyProjectAddon">( operationDefinitions.v1ApplyProjectAddon, input, + options, ); }), authorizeJitAccess: ( input: typeof operationDefinitions.v1AuthorizeJitAccess.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1AuthorizeJitAccess.outputSchema.Type, SupabaseApiError, @@ -89,10 +99,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1AuthorizeJitAccess">( operationDefinitions.v1AuthorizeJitAccess, input, + options, ); }), authorizeUser: ( input: typeof operationDefinitions.v1AuthorizeUser.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1AuthorizeUser.outputSchema.Type, SupabaseApiError, @@ -103,10 +115,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1AuthorizeUser">( operationDefinitions.v1AuthorizeUser, input, + options, ); }), bulkCreateSecrets: ( input: typeof operationDefinitions.v1BulkCreateSecrets.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1BulkCreateSecrets.outputSchema.Type, SupabaseApiError, @@ -117,10 +131,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1BulkCreateSecrets">( operationDefinitions.v1BulkCreateSecrets, input, + options, ); }), bulkDeleteSecrets: ( input: typeof operationDefinitions.v1BulkDeleteSecrets.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1BulkDeleteSecrets.outputSchema.Type, SupabaseApiError, @@ -131,10 +147,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1BulkDeleteSecrets">( operationDefinitions.v1BulkDeleteSecrets, input, + options, ); }), bulkUpdateFunctions: ( input: typeof operationDefinitions.v1BulkUpdateFunctions.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1BulkUpdateFunctions.outputSchema.Type, SupabaseApiError, @@ -145,10 +163,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1BulkUpdateFunctions">( operationDefinitions.v1BulkUpdateFunctions, input, + options, ); }), cancelAProjectRestoration: ( input: typeof operationDefinitions.v1CancelAProjectRestoration.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CancelAProjectRestoration.outputSchema.Type, SupabaseApiError, @@ -159,10 +179,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CancelAProjectRestoration">( operationDefinitions.v1CancelAProjectRestoration, input, + options, ); }), checkVanitySubdomainAvailability: ( input: typeof operationDefinitions.v1CheckVanitySubdomainAvailability.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CheckVanitySubdomainAvailability.outputSchema.Type, SupabaseApiError, @@ -173,10 +195,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CheckVanitySubdomainAvailability">( operationDefinitions.v1CheckVanitySubdomainAvailability, input, + options, ); }), claimProjectForOrganization: ( input: typeof operationDefinitions.v1ClaimProjectForOrganization.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ClaimProjectForOrganization.outputSchema.Type, SupabaseApiError, @@ -187,10 +211,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ClaimProjectForOrganization">( operationDefinitions.v1ClaimProjectForOrganization, input, + options, ); }), countActionRuns: ( input: typeof operationDefinitions.v1CountActionRuns.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CountActionRuns.outputSchema.Type, SupabaseApiError, @@ -201,10 +227,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CountActionRuns">( operationDefinitions.v1CountActionRuns, input, + options, ); }), createABranch: ( input: typeof operationDefinitions.v1CreateABranch.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateABranch.outputSchema.Type, SupabaseApiError, @@ -215,10 +243,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateABranch">( operationDefinitions.v1CreateABranch, input, + options, ); }), createAFunction: ( input: typeof operationDefinitions.v1CreateAFunction.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateAFunction.outputSchema.Type, SupabaseApiError, @@ -229,10 +259,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateAFunction">( operationDefinitions.v1CreateAFunction, input, + options, ); }), createAProject: ( input: typeof operationDefinitions.v1CreateAProject.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateAProject.outputSchema.Type, SupabaseApiError, @@ -243,10 +275,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateAProject">( operationDefinitions.v1CreateAProject, input, + options, ); }), createASsoProvider: ( input: typeof operationDefinitions.v1CreateASsoProvider.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateASsoProvider.outputSchema.Type, SupabaseApiError, @@ -257,10 +291,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateASsoProvider">( operationDefinitions.v1CreateASsoProvider, input, + options, ); }), createAnOrganization: ( input: typeof operationDefinitions.v1CreateAnOrganization.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateAnOrganization.outputSchema.Type, SupabaseApiError, @@ -271,10 +307,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateAnOrganization">( operationDefinitions.v1CreateAnOrganization, input, + options, ); }), createLegacySigningKey: ( input: typeof operationDefinitions.v1CreateLegacySigningKey.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateLegacySigningKey.outputSchema.Type, SupabaseApiError, @@ -285,10 +323,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateLegacySigningKey">( operationDefinitions.v1CreateLegacySigningKey, input, + options, ); }), createLoginRole: ( input: typeof operationDefinitions.v1CreateLoginRole.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateLoginRole.outputSchema.Type, SupabaseApiError, @@ -299,10 +339,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateLoginRole">( operationDefinitions.v1CreateLoginRole, input, + options, ); }), createProjectApiKey: ( input: typeof operationDefinitions.v1CreateProjectApiKey.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateProjectApiKey.outputSchema.Type, SupabaseApiError, @@ -313,10 +355,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateProjectApiKey">( operationDefinitions.v1CreateProjectApiKey, input, + options, ); }), createProjectClaimToken: ( input: typeof operationDefinitions.v1CreateProjectClaimToken.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateProjectClaimToken.outputSchema.Type, SupabaseApiError, @@ -327,10 +371,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateProjectClaimToken">( operationDefinitions.v1CreateProjectClaimToken, input, + options, ); }), createProjectSigningKey: ( input: typeof operationDefinitions.v1CreateProjectSigningKey.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateProjectSigningKey.outputSchema.Type, SupabaseApiError, @@ -341,10 +387,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateProjectSigningKey">( operationDefinitions.v1CreateProjectSigningKey, input, + options, ); }), createProjectTpaIntegration: ( input: typeof operationDefinitions.v1CreateProjectTpaIntegration.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateProjectTpaIntegration.outputSchema.Type, SupabaseApiError, @@ -355,10 +403,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateProjectTpaIntegration">( operationDefinitions.v1CreateProjectTpaIntegration, input, + options, ); }), createRestorePoint: ( input: typeof operationDefinitions.v1CreateRestorePoint.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1CreateRestorePoint.outputSchema.Type, SupabaseApiError, @@ -369,10 +419,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1CreateRestorePoint">( operationDefinitions.v1CreateRestorePoint, input, + options, ); }), deactivateVanitySubdomainConfig: ( input: typeof operationDefinitions.v1DeactivateVanitySubdomainConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeactivateVanitySubdomainConfig.outputSchema.Type, SupabaseApiError, @@ -383,10 +435,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeactivateVanitySubdomainConfig">( operationDefinitions.v1DeactivateVanitySubdomainConfig, input, + options, ); }), deleteHostnameConfig: ( input: typeof operationDefinitions.v1DeleteHostnameConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteHostnameConfig.outputSchema.Type, SupabaseApiError, @@ -397,10 +451,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteHostnameConfig">( operationDefinitions.v1DeleteHostnameConfig, input, + options, ); }), deleteABranch: ( input: typeof operationDefinitions.v1DeleteABranch.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteABranch.outputSchema.Type, SupabaseApiError, @@ -411,10 +467,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteABranch">( operationDefinitions.v1DeleteABranch, input, + options, ); }), deleteAFunction: ( input: typeof operationDefinitions.v1DeleteAFunction.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteAFunction.outputSchema.Type, SupabaseApiError, @@ -425,10 +483,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteAFunction">( operationDefinitions.v1DeleteAFunction, input, + options, ); }), deleteAProject: ( input: typeof operationDefinitions.v1DeleteAProject.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteAProject.outputSchema.Type, SupabaseApiError, @@ -439,10 +499,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteAProject">( operationDefinitions.v1DeleteAProject, input, + options, ); }), deleteASsoProvider: ( input: typeof operationDefinitions.v1DeleteASsoProvider.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteASsoProvider.outputSchema.Type, SupabaseApiError, @@ -453,10 +515,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteASsoProvider">( operationDefinitions.v1DeleteASsoProvider, input, + options, ); }), deleteInviteExternalJitAccess: ( input: typeof operationDefinitions.v1DeleteInviteExternalJitAccess.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteInviteExternalJitAccess.outputSchema.Type, SupabaseApiError, @@ -467,10 +531,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteInviteExternalJitAccess">( operationDefinitions.v1DeleteInviteExternalJitAccess, input, + options, ); }), deleteJitAccess: ( input: typeof operationDefinitions.v1DeleteJitAccess.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteJitAccess.outputSchema.Type, SupabaseApiError, @@ -481,10 +547,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteJitAccess">( operationDefinitions.v1DeleteJitAccess, input, + options, ); }), deleteLoginRoles: ( input: typeof operationDefinitions.v1DeleteLoginRoles.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteLoginRoles.outputSchema.Type, SupabaseApiError, @@ -495,10 +563,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteLoginRoles">( operationDefinitions.v1DeleteLoginRoles, input, + options, ); }), deleteNetworkBans: ( input: typeof operationDefinitions.v1DeleteNetworkBans.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteNetworkBans.outputSchema.Type, SupabaseApiError, @@ -509,10 +579,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteNetworkBans">( operationDefinitions.v1DeleteNetworkBans, input, + options, ); }), deleteProjectApiKey: ( input: typeof operationDefinitions.v1DeleteProjectApiKey.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteProjectApiKey.outputSchema.Type, SupabaseApiError, @@ -523,10 +595,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteProjectApiKey">( operationDefinitions.v1DeleteProjectApiKey, input, + options, ); }), deleteProjectClaimToken: ( input: typeof operationDefinitions.v1DeleteProjectClaimToken.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteProjectClaimToken.outputSchema.Type, SupabaseApiError, @@ -537,10 +611,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteProjectClaimToken">( operationDefinitions.v1DeleteProjectClaimToken, input, + options, ); }), deleteProjectTpaIntegration: ( input: typeof operationDefinitions.v1DeleteProjectTpaIntegration.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeleteProjectTpaIntegration.outputSchema.Type, SupabaseApiError, @@ -551,10 +627,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeleteProjectTpaIntegration">( operationDefinitions.v1DeleteProjectTpaIntegration, input, + options, ); }), deployAFunction: ( input: typeof operationDefinitions.v1DeployAFunction.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DeployAFunction.outputSchema.Type, SupabaseApiError, @@ -565,10 +643,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DeployAFunction">( operationDefinitions.v1DeployAFunction, input, + options, ); }), diffABranch: ( input: typeof operationDefinitions.v1DiffABranch.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DiffABranch.outputSchema.Type, SupabaseApiError, @@ -576,10 +656,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1DiffABranch">(operationDefinitions.v1DiffABranch, input); + return yield* client.execute<"v1DiffABranch">( + operationDefinitions.v1DiffABranch, + input, + options, + ); }), disablePreviewBranching: ( input: typeof operationDefinitions.v1DisablePreviewBranching.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DisablePreviewBranching.outputSchema.Type, SupabaseApiError, @@ -590,10 +675,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DisablePreviewBranching">( operationDefinitions.v1DisablePreviewBranching, input, + options, ); }), disableReadonlyModeTemporarily: ( input: typeof operationDefinitions.v1DisableReadonlyModeTemporarily.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1DisableReadonlyModeTemporarily.outputSchema.Type, SupabaseApiError, @@ -604,10 +691,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1DisableReadonlyModeTemporarily">( operationDefinitions.v1DisableReadonlyModeTemporarily, input, + options, ); }), enableDatabaseWebhook: ( input: typeof operationDefinitions.v1EnableDatabaseWebhook.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1EnableDatabaseWebhook.outputSchema.Type, SupabaseApiError, @@ -618,10 +707,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1EnableDatabaseWebhook">( operationDefinitions.v1EnableDatabaseWebhook, input, + options, ); }), exchangeOauthToken: ( input: typeof operationDefinitions.v1ExchangeOauthToken.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ExchangeOauthToken.outputSchema.Type, SupabaseApiError, @@ -632,10 +723,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ExchangeOauthToken">( operationDefinitions.v1ExchangeOauthToken, input, + options, ); }), generateTypescriptTypes: ( input: typeof operationDefinitions.v1GenerateTypescriptTypes.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GenerateTypescriptTypes.outputSchema.Type, SupabaseApiError, @@ -646,10 +739,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GenerateTypescriptTypes">( operationDefinitions.v1GenerateTypescriptTypes, input, + options, ); }), getABranch: ( input: typeof operationDefinitions.v1GetABranch.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetABranch.outputSchema.Type, SupabaseApiError, @@ -657,10 +752,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1GetABranch">(operationDefinitions.v1GetABranch, input); + return yield* client.execute<"v1GetABranch">( + operationDefinitions.v1GetABranch, + input, + options, + ); }), getABranchConfig: ( input: typeof operationDefinitions.v1GetABranchConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetABranchConfig.outputSchema.Type, SupabaseApiError, @@ -671,10 +771,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetABranchConfig">( operationDefinitions.v1GetABranchConfig, input, + options, ); }), getAFunction: ( input: typeof operationDefinitions.v1GetAFunction.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetAFunction.outputSchema.Type, SupabaseApiError, @@ -682,10 +784,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1GetAFunction">(operationDefinitions.v1GetAFunction, input); + return yield* client.execute<"v1GetAFunction">( + operationDefinitions.v1GetAFunction, + input, + options, + ); }), getAFunctionBody: ( input: typeof operationDefinitions.v1GetAFunctionBody.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetAFunctionBody.outputSchema.Type, SupabaseApiError, @@ -696,10 +803,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetAFunctionBody">( operationDefinitions.v1GetAFunctionBody, input, + options, ); }), getAMigration: ( input: typeof operationDefinitions.v1GetAMigration.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetAMigration.outputSchema.Type, SupabaseApiError, @@ -710,10 +819,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetAMigration">( operationDefinitions.v1GetAMigration, input, + options, ); }), getASnippet: ( input: typeof operationDefinitions.v1GetASnippet.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetASnippet.outputSchema.Type, SupabaseApiError, @@ -721,10 +832,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1GetASnippet">(operationDefinitions.v1GetASnippet, input); + return yield* client.execute<"v1GetASnippet">( + operationDefinitions.v1GetASnippet, + input, + options, + ); }), getASsoProvider: ( input: typeof operationDefinitions.v1GetASsoProvider.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetASsoProvider.outputSchema.Type, SupabaseApiError, @@ -735,10 +851,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetASsoProvider">( operationDefinitions.v1GetASsoProvider, input, + options, ); }), getActionRun: ( input: typeof operationDefinitions.v1GetActionRun.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetActionRun.outputSchema.Type, SupabaseApiError, @@ -746,10 +864,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1GetActionRun">(operationDefinitions.v1GetActionRun, input); + return yield* client.execute<"v1GetActionRun">( + operationDefinitions.v1GetActionRun, + input, + options, + ); }), getActionRunLogs: ( input: typeof operationDefinitions.v1GetActionRunLogs.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetActionRunLogs.outputSchema.Type, SupabaseApiError, @@ -760,10 +883,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetActionRunLogs">( operationDefinitions.v1GetActionRunLogs, input, + options, ); }), getAllProjectsForOrganization: ( input: typeof operationDefinitions.v1GetAllProjectsForOrganization.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetAllProjectsForOrganization.outputSchema.Type, SupabaseApiError, @@ -774,10 +899,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetAllProjectsForOrganization">( operationDefinitions.v1GetAllProjectsForOrganization, input, + options, ); }), getAnOrganization: ( input: typeof operationDefinitions.v1GetAnOrganization.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetAnOrganization.outputSchema.Type, SupabaseApiError, @@ -788,10 +915,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetAnOrganization">( operationDefinitions.v1GetAnOrganization, input, + options, ); }), getAuthServiceConfig: ( input: typeof operationDefinitions.v1GetAuthServiceConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetAuthServiceConfig.outputSchema.Type, SupabaseApiError, @@ -802,10 +931,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetAuthServiceConfig">( operationDefinitions.v1GetAuthServiceConfig, input, + options, ); }), getAvailableRegions: ( input: typeof operationDefinitions.v1GetAvailableRegions.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetAvailableRegions.outputSchema.Type, SupabaseApiError, @@ -816,10 +947,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetAvailableRegions">( operationDefinitions.v1GetAvailableRegions, input, + options, ); }), getBackupSchedule: ( input: typeof operationDefinitions.v1GetBackupSchedule.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetBackupSchedule.outputSchema.Type, SupabaseApiError, @@ -830,10 +963,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetBackupSchedule">( operationDefinitions.v1GetBackupSchedule, input, + options, ); }), getDatabaseDisk: ( input: typeof operationDefinitions.v1GetDatabaseDisk.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetDatabaseDisk.outputSchema.Type, SupabaseApiError, @@ -844,10 +979,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetDatabaseDisk">( operationDefinitions.v1GetDatabaseDisk, input, + options, ); }), getDatabaseMetadata: ( input: typeof operationDefinitions.v1GetDatabaseMetadata.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetDatabaseMetadata.outputSchema.Type, SupabaseApiError, @@ -858,10 +995,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetDatabaseMetadata">( operationDefinitions.v1GetDatabaseMetadata, input, + options, ); }), getDatabaseOpenapi: ( input: typeof operationDefinitions.v1GetDatabaseOpenapi.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetDatabaseOpenapi.outputSchema.Type, SupabaseApiError, @@ -872,10 +1011,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetDatabaseOpenapi">( operationDefinitions.v1GetDatabaseOpenapi, input, + options, ); }), getDiskUtilization: ( input: typeof operationDefinitions.v1GetDiskUtilization.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetDiskUtilization.outputSchema.Type, SupabaseApiError, @@ -886,10 +1027,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetDiskUtilization">( operationDefinitions.v1GetDiskUtilization, input, + options, ); }), getHostnameConfig: ( input: typeof operationDefinitions.v1GetHostnameConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetHostnameConfig.outputSchema.Type, SupabaseApiError, @@ -900,10 +1043,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetHostnameConfig">( operationDefinitions.v1GetHostnameConfig, input, + options, ); }), getJitAccess: ( input: typeof operationDefinitions.v1GetJitAccess.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetJitAccess.outputSchema.Type, SupabaseApiError, @@ -911,10 +1056,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1GetJitAccess">(operationDefinitions.v1GetJitAccess, input); + return yield* client.execute<"v1GetJitAccess">( + operationDefinitions.v1GetJitAccess, + input, + options, + ); }), getJitAccessConfig: ( input: typeof operationDefinitions.v1GetJitAccessConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetJitAccessConfig.outputSchema.Type, SupabaseApiError, @@ -925,10 +1075,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetJitAccessConfig">( operationDefinitions.v1GetJitAccessConfig, input, + options, ); }), getLegacySigningKey: ( input: typeof operationDefinitions.v1GetLegacySigningKey.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetLegacySigningKey.outputSchema.Type, SupabaseApiError, @@ -939,10 +1091,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetLegacySigningKey">( operationDefinitions.v1GetLegacySigningKey, input, + options, ); }), getNetworkRestrictions: ( input: typeof operationDefinitions.v1GetNetworkRestrictions.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetNetworkRestrictions.outputSchema.Type, SupabaseApiError, @@ -953,10 +1107,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetNetworkRestrictions">( operationDefinitions.v1GetNetworkRestrictions, input, + options, ); }), getOrganizationEntitlements: ( input: typeof operationDefinitions.v1GetOrganizationEntitlements.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetOrganizationEntitlements.outputSchema.Type, SupabaseApiError, @@ -967,10 +1123,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetOrganizationEntitlements">( operationDefinitions.v1GetOrganizationEntitlements, input, + options, ); }), getOrganizationProjectClaim: ( input: typeof operationDefinitions.v1GetOrganizationProjectClaim.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetOrganizationProjectClaim.outputSchema.Type, SupabaseApiError, @@ -981,10 +1139,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetOrganizationProjectClaim">( operationDefinitions.v1GetOrganizationProjectClaim, input, + options, ); }), getPerformanceAdvisors: ( input: typeof operationDefinitions.v1GetPerformanceAdvisors.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetPerformanceAdvisors.outputSchema.Type, SupabaseApiError, @@ -995,10 +1155,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetPerformanceAdvisors">( operationDefinitions.v1GetPerformanceAdvisors, input, + options, ); }), getPgsodiumConfig: ( input: typeof operationDefinitions.v1GetPgsodiumConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetPgsodiumConfig.outputSchema.Type, SupabaseApiError, @@ -1009,10 +1171,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetPgsodiumConfig">( operationDefinitions.v1GetPgsodiumConfig, input, + options, ); }), getPoolerConfig: ( input: typeof operationDefinitions.v1GetPoolerConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetPoolerConfig.outputSchema.Type, SupabaseApiError, @@ -1023,10 +1187,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetPoolerConfig">( operationDefinitions.v1GetPoolerConfig, input, + options, ); }), getPostgresConfig: ( input: typeof operationDefinitions.v1GetPostgresConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetPostgresConfig.outputSchema.Type, SupabaseApiError, @@ -1037,10 +1203,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetPostgresConfig">( operationDefinitions.v1GetPostgresConfig, input, + options, ); }), getPostgresUpgradeEligibility: ( input: typeof operationDefinitions.v1GetPostgresUpgradeEligibility.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetPostgresUpgradeEligibility.outputSchema.Type, SupabaseApiError, @@ -1051,10 +1219,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetPostgresUpgradeEligibility">( operationDefinitions.v1GetPostgresUpgradeEligibility, input, + options, ); }), getPostgresUpgradeStatus: ( input: typeof operationDefinitions.v1GetPostgresUpgradeStatus.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetPostgresUpgradeStatus.outputSchema.Type, SupabaseApiError, @@ -1065,10 +1235,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetPostgresUpgradeStatus">( operationDefinitions.v1GetPostgresUpgradeStatus, input, + options, ); }), getPostgrestServiceConfig: ( input: typeof operationDefinitions.v1GetPostgrestServiceConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetPostgrestServiceConfig.outputSchema.Type, SupabaseApiError, @@ -1079,19 +1251,28 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetPostgrestServiceConfig">( operationDefinitions.v1GetPostgrestServiceConfig, input, + options, ); }), - getProfile: (): Effect.Effect< + getProfile: ( + _input: Record = {}, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< typeof operationDefinitions.v1GetProfile.outputSchema.Type, SupabaseApiError, SupabaseApiClient > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1GetProfile">(operationDefinitions.v1GetProfile, {}); + return yield* client.execute<"v1GetProfile">( + operationDefinitions.v1GetProfile, + {}, + options, + ); }), getProject: ( input: typeof operationDefinitions.v1GetProject.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProject.outputSchema.Type, SupabaseApiError, @@ -1099,10 +1280,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1GetProject">(operationDefinitions.v1GetProject, input); + return yield* client.execute<"v1GetProject">( + operationDefinitions.v1GetProject, + input, + options, + ); }), getProjectApiKey: ( input: typeof operationDefinitions.v1GetProjectApiKey.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectApiKey.outputSchema.Type, SupabaseApiError, @@ -1113,10 +1299,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectApiKey">( operationDefinitions.v1GetProjectApiKey, input, + options, ); }), getProjectApiKeys: ( input: typeof operationDefinitions.v1GetProjectApiKeys.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectApiKeys.outputSchema.Type, SupabaseApiError, @@ -1127,10 +1315,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectApiKeys">( operationDefinitions.v1GetProjectApiKeys, input, + options, ); }), getProjectClaimToken: ( input: typeof operationDefinitions.v1GetProjectClaimToken.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectClaimToken.outputSchema.Type, SupabaseApiError, @@ -1141,10 +1331,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectClaimToken">( operationDefinitions.v1GetProjectClaimToken, input, + options, ); }), getProjectDiskAutoscaleConfig: ( input: typeof operationDefinitions.v1GetProjectDiskAutoscaleConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectDiskAutoscaleConfig.outputSchema.Type, SupabaseApiError, @@ -1155,10 +1347,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectDiskAutoscaleConfig">( operationDefinitions.v1GetProjectDiskAutoscaleConfig, input, + options, ); }), getProjectFunctionCombinedStats: ( input: typeof operationDefinitions.v1GetProjectFunctionCombinedStats.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectFunctionCombinedStats.outputSchema.Type, SupabaseApiError, @@ -1169,10 +1363,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectFunctionCombinedStats">( operationDefinitions.v1GetProjectFunctionCombinedStats, input, + options, ); }), getProjectLegacyApiKeys: ( input: typeof operationDefinitions.v1GetProjectLegacyApiKeys.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectLegacyApiKeys.outputSchema.Type, SupabaseApiError, @@ -1183,10 +1379,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectLegacyApiKeys">( operationDefinitions.v1GetProjectLegacyApiKeys, input, + options, ); }), getProjectLogs: ( input: typeof operationDefinitions.v1GetProjectLogs.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectLogs.outputSchema.Type, SupabaseApiError, @@ -1197,10 +1395,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectLogs">( operationDefinitions.v1GetProjectLogs, input, + options, ); }), getProjectLogsAll: ( input: typeof operationDefinitions.v1GetProjectLogsAll.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectLogsAll.outputSchema.Type, SupabaseApiError, @@ -1211,10 +1411,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectLogsAll">( operationDefinitions.v1GetProjectLogsAll, input, + options, ); }), getProjectPgbouncerConfig: ( input: typeof operationDefinitions.v1GetProjectPgbouncerConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectPgbouncerConfig.outputSchema.Type, SupabaseApiError, @@ -1225,10 +1427,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectPgbouncerConfig">( operationDefinitions.v1GetProjectPgbouncerConfig, input, + options, ); }), getProjectSigningKey: ( input: typeof operationDefinitions.v1GetProjectSigningKey.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectSigningKey.outputSchema.Type, SupabaseApiError, @@ -1239,10 +1443,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectSigningKey">( operationDefinitions.v1GetProjectSigningKey, input, + options, ); }), getProjectSigningKeys: ( input: typeof operationDefinitions.v1GetProjectSigningKeys.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectSigningKeys.outputSchema.Type, SupabaseApiError, @@ -1253,10 +1459,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectSigningKeys">( operationDefinitions.v1GetProjectSigningKeys, input, + options, ); }), getProjectTpaIntegration: ( input: typeof operationDefinitions.v1GetProjectTpaIntegration.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectTpaIntegration.outputSchema.Type, SupabaseApiError, @@ -1267,10 +1475,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectTpaIntegration">( operationDefinitions.v1GetProjectTpaIntegration, input, + options, ); }), getProjectUsageApiCount: ( input: typeof operationDefinitions.v1GetProjectUsageApiCount.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectUsageApiCount.outputSchema.Type, SupabaseApiError, @@ -1281,10 +1491,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectUsageApiCount">( operationDefinitions.v1GetProjectUsageApiCount, input, + options, ); }), getProjectUsageRequestCount: ( input: typeof operationDefinitions.v1GetProjectUsageRequestCount.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetProjectUsageRequestCount.outputSchema.Type, SupabaseApiError, @@ -1295,10 +1507,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetProjectUsageRequestCount">( operationDefinitions.v1GetProjectUsageRequestCount, input, + options, ); }), getReadonlyModeStatus: ( input: typeof operationDefinitions.v1GetReadonlyModeStatus.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetReadonlyModeStatus.outputSchema.Type, SupabaseApiError, @@ -1309,10 +1523,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetReadonlyModeStatus">( operationDefinitions.v1GetReadonlyModeStatus, input, + options, ); }), getRealtimeConfig: ( input: typeof operationDefinitions.v1GetRealtimeConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetRealtimeConfig.outputSchema.Type, SupabaseApiError, @@ -1323,10 +1539,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetRealtimeConfig">( operationDefinitions.v1GetRealtimeConfig, input, + options, ); }), getRestorePoint: ( input: typeof operationDefinitions.v1GetRestorePoint.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetRestorePoint.outputSchema.Type, SupabaseApiError, @@ -1337,10 +1555,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetRestorePoint">( operationDefinitions.v1GetRestorePoint, input, + options, ); }), getSecurityAdvisors: ( input: typeof operationDefinitions.v1GetSecurityAdvisors.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetSecurityAdvisors.outputSchema.Type, SupabaseApiError, @@ -1351,10 +1571,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetSecurityAdvisors">( operationDefinitions.v1GetSecurityAdvisors, input, + options, ); }), getServicesHealth: ( input: typeof operationDefinitions.v1GetServicesHealth.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetServicesHealth.outputSchema.Type, SupabaseApiError, @@ -1365,10 +1587,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetServicesHealth">( operationDefinitions.v1GetServicesHealth, input, + options, ); }), getSslEnforcementConfig: ( input: typeof operationDefinitions.v1GetSslEnforcementConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetSslEnforcementConfig.outputSchema.Type, SupabaseApiError, @@ -1379,10 +1603,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetSslEnforcementConfig">( operationDefinitions.v1GetSslEnforcementConfig, input, + options, ); }), getStorageConfig: ( input: typeof operationDefinitions.v1GetStorageConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetStorageConfig.outputSchema.Type, SupabaseApiError, @@ -1393,10 +1619,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetStorageConfig">( operationDefinitions.v1GetStorageConfig, input, + options, ); }), getVanitySubdomainConfig: ( input: typeof operationDefinitions.v1GetVanitySubdomainConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1GetVanitySubdomainConfig.outputSchema.Type, SupabaseApiError, @@ -1407,10 +1635,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1GetVanitySubdomainConfig">( operationDefinitions.v1GetVanitySubdomainConfig, input, + options, ); }), inviteExternalJitAccess: ( input: typeof operationDefinitions.v1InviteExternalJitAccess.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1InviteExternalJitAccess.outputSchema.Type, SupabaseApiError, @@ -1421,10 +1651,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1InviteExternalJitAccess">( operationDefinitions.v1InviteExternalJitAccess, input, + options, ); }), listActionRuns: ( input: typeof operationDefinitions.v1ListActionRuns.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListActionRuns.outputSchema.Type, SupabaseApiError, @@ -1435,10 +1667,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListActionRuns">( operationDefinitions.v1ListActionRuns, input, + options, ); }), listAllBackups: ( input: typeof operationDefinitions.v1ListAllBackups.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListAllBackups.outputSchema.Type, SupabaseApiError, @@ -1449,10 +1683,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllBackups">( operationDefinitions.v1ListAllBackups, input, + options, ); }), listAllBranches: ( input: typeof operationDefinitions.v1ListAllBranches.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListAllBranches.outputSchema.Type, SupabaseApiError, @@ -1463,10 +1699,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllBranches">( operationDefinitions.v1ListAllBranches, input, + options, ); }), listAllBuckets: ( input: typeof operationDefinitions.v1ListAllBuckets.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListAllBuckets.outputSchema.Type, SupabaseApiError, @@ -1477,10 +1715,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllBuckets">( operationDefinitions.v1ListAllBuckets, input, + options, ); }), listAllFunctions: ( input: typeof operationDefinitions.v1ListAllFunctions.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListAllFunctions.outputSchema.Type, SupabaseApiError, @@ -1491,10 +1731,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllFunctions">( operationDefinitions.v1ListAllFunctions, input, + options, ); }), listAllNetworkBans: ( input: typeof operationDefinitions.v1ListAllNetworkBans.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListAllNetworkBans.outputSchema.Type, SupabaseApiError, @@ -1505,10 +1747,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllNetworkBans">( operationDefinitions.v1ListAllNetworkBans, input, + options, ); }), listAllNetworkBansEnriched: ( input: typeof operationDefinitions.v1ListAllNetworkBansEnriched.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListAllNetworkBansEnriched.outputSchema.Type, SupabaseApiError, @@ -1519,9 +1763,13 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllNetworkBansEnriched">( operationDefinitions.v1ListAllNetworkBansEnriched, input, + options, ); }), - listAllOrganizations: (): Effect.Effect< + listAllOrganizations: ( + _input: Record = {}, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< typeof operationDefinitions.v1ListAllOrganizations.outputSchema.Type, SupabaseApiError, SupabaseApiClient @@ -1531,9 +1779,13 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllOrganizations">( operationDefinitions.v1ListAllOrganizations, {}, + options, ); }), - listAllProjects: (): Effect.Effect< + listAllProjects: ( + _input: Record = {}, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< typeof operationDefinitions.v1ListAllProjects.outputSchema.Type, SupabaseApiError, SupabaseApiClient @@ -1543,10 +1795,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllProjects">( operationDefinitions.v1ListAllProjects, {}, + options, ); }), listAllSecrets: ( input: typeof operationDefinitions.v1ListAllSecrets.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListAllSecrets.outputSchema.Type, SupabaseApiError, @@ -1557,10 +1811,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllSecrets">( operationDefinitions.v1ListAllSecrets, input, + options, ); }), listAllSnippets: ( input: typeof operationDefinitions.v1ListAllSnippets.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListAllSnippets.outputSchema.Type, SupabaseApiError, @@ -1571,10 +1827,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllSnippets">( operationDefinitions.v1ListAllSnippets, input, + options, ); }), listAllSsoProvider: ( input: typeof operationDefinitions.v1ListAllSsoProvider.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListAllSsoProvider.outputSchema.Type, SupabaseApiError, @@ -1585,10 +1843,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAllSsoProvider">( operationDefinitions.v1ListAllSsoProvider, input, + options, ); }), listAvailableRestoreVersions: ( input: typeof operationDefinitions.v1ListAvailableRestoreVersions.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListAvailableRestoreVersions.outputSchema.Type, SupabaseApiError, @@ -1599,10 +1859,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListAvailableRestoreVersions">( operationDefinitions.v1ListAvailableRestoreVersions, input, + options, ); }), listJitAccess: ( input: typeof operationDefinitions.v1ListJitAccess.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListJitAccess.outputSchema.Type, SupabaseApiError, @@ -1613,10 +1875,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListJitAccess">( operationDefinitions.v1ListJitAccess, input, + options, ); }), listMigrationHistory: ( input: typeof operationDefinitions.v1ListMigrationHistory.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListMigrationHistory.outputSchema.Type, SupabaseApiError, @@ -1627,10 +1891,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListMigrationHistory">( operationDefinitions.v1ListMigrationHistory, input, + options, ); }), listOrganizationMembers: ( input: typeof operationDefinitions.v1ListOrganizationMembers.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListOrganizationMembers.outputSchema.Type, SupabaseApiError, @@ -1641,10 +1907,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListOrganizationMembers">( operationDefinitions.v1ListOrganizationMembers, input, + options, ); }), listProjectAddons: ( input: typeof operationDefinitions.v1ListProjectAddons.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListProjectAddons.outputSchema.Type, SupabaseApiError, @@ -1655,10 +1923,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListProjectAddons">( operationDefinitions.v1ListProjectAddons, input, + options, ); }), listProjectTpaIntegrations: ( input: typeof operationDefinitions.v1ListProjectTpaIntegrations.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ListProjectTpaIntegrations.outputSchema.Type, SupabaseApiError, @@ -1669,10 +1939,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ListProjectTpaIntegrations">( operationDefinitions.v1ListProjectTpaIntegrations, input, + options, ); }), mergeABranch: ( input: typeof operationDefinitions.v1MergeABranch.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1MergeABranch.outputSchema.Type, SupabaseApiError, @@ -1680,10 +1952,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1MergeABranch">(operationDefinitions.v1MergeABranch, input); + return yield* client.execute<"v1MergeABranch">( + operationDefinitions.v1MergeABranch, + input, + options, + ); }), modifyDatabaseDisk: ( input: typeof operationDefinitions.v1ModifyDatabaseDisk.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ModifyDatabaseDisk.outputSchema.Type, SupabaseApiError, @@ -1694,10 +1971,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ModifyDatabaseDisk">( operationDefinitions.v1ModifyDatabaseDisk, input, + options, ); }), oauthAuthorizeProjectClaim: ( input: typeof operationDefinitions.v1OauthAuthorizeProjectClaim.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1OauthAuthorizeProjectClaim.outputSchema.Type, SupabaseApiError, @@ -1708,10 +1987,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1OauthAuthorizeProjectClaim">( operationDefinitions.v1OauthAuthorizeProjectClaim, input, + options, ); }), patchAMigration: ( input: typeof operationDefinitions.v1PatchAMigration.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1PatchAMigration.outputSchema.Type, SupabaseApiError, @@ -1722,10 +2003,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1PatchAMigration">( operationDefinitions.v1PatchAMigration, input, + options, ); }), patchNetworkRestrictions: ( input: typeof operationDefinitions.v1PatchNetworkRestrictions.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1PatchNetworkRestrictions.outputSchema.Type, SupabaseApiError, @@ -1736,10 +2019,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1PatchNetworkRestrictions">( operationDefinitions.v1PatchNetworkRestrictions, input, + options, ); }), pauseAProject: ( input: typeof operationDefinitions.v1PauseAProject.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1PauseAProject.outputSchema.Type, SupabaseApiError, @@ -1750,10 +2035,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1PauseAProject">( operationDefinitions.v1PauseAProject, input, + options, ); }), pushABranch: ( input: typeof operationDefinitions.v1PushABranch.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1PushABranch.outputSchema.Type, SupabaseApiError, @@ -1761,10 +2048,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1PushABranch">(operationDefinitions.v1PushABranch, input); + return yield* client.execute<"v1PushABranch">( + operationDefinitions.v1PushABranch, + input, + options, + ); }), readOnlyQuery: ( input: typeof operationDefinitions.v1ReadOnlyQuery.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ReadOnlyQuery.outputSchema.Type, SupabaseApiError, @@ -1775,10 +2067,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ReadOnlyQuery">( operationDefinitions.v1ReadOnlyQuery, input, + options, ); }), removeAReadReplica: ( input: typeof operationDefinitions.v1RemoveAReadReplica.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RemoveAReadReplica.outputSchema.Type, SupabaseApiError, @@ -1789,10 +2083,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1RemoveAReadReplica">( operationDefinitions.v1RemoveAReadReplica, input, + options, ); }), removeProjectAddon: ( input: typeof operationDefinitions.v1RemoveProjectAddon.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RemoveProjectAddon.outputSchema.Type, SupabaseApiError, @@ -1803,10 +2099,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1RemoveProjectAddon">( operationDefinitions.v1RemoveProjectAddon, input, + options, ); }), removeProjectSigningKey: ( input: typeof operationDefinitions.v1RemoveProjectSigningKey.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RemoveProjectSigningKey.outputSchema.Type, SupabaseApiError, @@ -1817,10 +2115,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1RemoveProjectSigningKey">( operationDefinitions.v1RemoveProjectSigningKey, input, + options, ); }), resetABranch: ( input: typeof operationDefinitions.v1ResetABranch.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ResetABranch.outputSchema.Type, SupabaseApiError, @@ -1828,10 +2128,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1ResetABranch">(operationDefinitions.v1ResetABranch, input); + return yield* client.execute<"v1ResetABranch">( + operationDefinitions.v1ResetABranch, + input, + options, + ); }), restartAProject: ( input: typeof operationDefinitions.v1RestartAProject.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RestartAProject.outputSchema.Type, SupabaseApiError, @@ -1842,10 +2147,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1RestartAProject">( operationDefinitions.v1RestartAProject, input, + options, ); }), restoreABranch: ( input: typeof operationDefinitions.v1RestoreABranch.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RestoreABranch.outputSchema.Type, SupabaseApiError, @@ -1856,10 +2163,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1RestoreABranch">( operationDefinitions.v1RestoreABranch, input, + options, ); }), restoreAProject: ( input: typeof operationDefinitions.v1RestoreAProject.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RestoreAProject.outputSchema.Type, SupabaseApiError, @@ -1870,10 +2179,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1RestoreAProject">( operationDefinitions.v1RestoreAProject, input, + options, ); }), restorePhysicalBackup: ( input: typeof operationDefinitions.v1RestorePhysicalBackup.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RestorePhysicalBackup.outputSchema.Type, SupabaseApiError, @@ -1884,10 +2195,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1RestorePhysicalBackup">( operationDefinitions.v1RestorePhysicalBackup, input, + options, ); }), restorePitrBackup: ( input: typeof operationDefinitions.v1RestorePitrBackup.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RestorePitrBackup.outputSchema.Type, SupabaseApiError, @@ -1898,10 +2211,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1RestorePitrBackup">( operationDefinitions.v1RestorePitrBackup, input, + options, ); }), revokeToken: ( input: typeof operationDefinitions.v1RevokeToken.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RevokeToken.outputSchema.Type, SupabaseApiError, @@ -1909,10 +2224,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1RevokeToken">(operationDefinitions.v1RevokeToken, input); + return yield* client.execute<"v1RevokeToken">( + operationDefinitions.v1RevokeToken, + input, + options, + ); }), rollbackMigrations: ( input: typeof operationDefinitions.v1RollbackMigrations.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RollbackMigrations.outputSchema.Type, SupabaseApiError, @@ -1923,10 +2243,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1RollbackMigrations">( operationDefinitions.v1RollbackMigrations, input, + options, ); }), runAQuery: ( input: typeof operationDefinitions.v1RunAQuery.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1RunAQuery.outputSchema.Type, SupabaseApiError, @@ -1934,10 +2256,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1RunAQuery">(operationDefinitions.v1RunAQuery, input); + return yield* client.execute<"v1RunAQuery">( + operationDefinitions.v1RunAQuery, + input, + options, + ); }), scrapeProjectMetrics: ( input: typeof operationDefinitions.v1ScrapeProjectMetrics.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ScrapeProjectMetrics.outputSchema.Type, SupabaseApiError, @@ -1948,10 +2275,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ScrapeProjectMetrics">( operationDefinitions.v1ScrapeProjectMetrics, input, + options, ); }), setupAReadReplica: ( input: typeof operationDefinitions.v1SetupAReadReplica.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1SetupAReadReplica.outputSchema.Type, SupabaseApiError, @@ -1962,10 +2291,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1SetupAReadReplica">( operationDefinitions.v1SetupAReadReplica, input, + options, ); }), shutdownRealtime: ( input: typeof operationDefinitions.v1ShutdownRealtime.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1ShutdownRealtime.outputSchema.Type, SupabaseApiError, @@ -1976,10 +2307,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1ShutdownRealtime">( operationDefinitions.v1ShutdownRealtime, input, + options, ); }), undo: ( input: typeof operationDefinitions.v1Undo.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1Undo.outputSchema.Type, SupabaseApiError, @@ -1987,10 +2320,11 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v1Undo">(operationDefinitions.v1Undo, input); + return yield* client.execute<"v1Undo">(operationDefinitions.v1Undo, input, options); }), updateABranchConfig: ( input: typeof operationDefinitions.v1UpdateABranchConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateABranchConfig.outputSchema.Type, SupabaseApiError, @@ -2001,10 +2335,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateABranchConfig">( operationDefinitions.v1UpdateABranchConfig, input, + options, ); }), updateAFunction: ( input: typeof operationDefinitions.v1UpdateAFunction.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateAFunction.outputSchema.Type, SupabaseApiError, @@ -2015,10 +2351,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateAFunction">( operationDefinitions.v1UpdateAFunction, input, + options, ); }), updateAProject: ( input: typeof operationDefinitions.v1UpdateAProject.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateAProject.outputSchema.Type, SupabaseApiError, @@ -2029,10 +2367,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateAProject">( operationDefinitions.v1UpdateAProject, input, + options, ); }), updateASsoProvider: ( input: typeof operationDefinitions.v1UpdateASsoProvider.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateASsoProvider.outputSchema.Type, SupabaseApiError, @@ -2043,10 +2383,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateASsoProvider">( operationDefinitions.v1UpdateASsoProvider, input, + options, ); }), updateActionRunStatus: ( input: typeof operationDefinitions.v1UpdateActionRunStatus.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateActionRunStatus.outputSchema.Type, SupabaseApiError, @@ -2057,10 +2399,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateActionRunStatus">( operationDefinitions.v1UpdateActionRunStatus, input, + options, ); }), updateAuthServiceConfig: ( input: typeof operationDefinitions.v1UpdateAuthServiceConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateAuthServiceConfig.outputSchema.Type, SupabaseApiError, @@ -2071,10 +2415,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateAuthServiceConfig">( operationDefinitions.v1UpdateAuthServiceConfig, input, + options, ); }), updateBackupSchedule: ( input: typeof operationDefinitions.v1UpdateBackupSchedule.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateBackupSchedule.outputSchema.Type, SupabaseApiError, @@ -2085,10 +2431,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateBackupSchedule">( operationDefinitions.v1UpdateBackupSchedule, input, + options, ); }), updateDatabasePassword: ( input: typeof operationDefinitions.v1UpdateDatabasePassword.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateDatabasePassword.outputSchema.Type, SupabaseApiError, @@ -2099,10 +2447,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateDatabasePassword">( operationDefinitions.v1UpdateDatabasePassword, input, + options, ); }), updateHostnameConfig: ( input: typeof operationDefinitions.v1UpdateHostnameConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateHostnameConfig.outputSchema.Type, SupabaseApiError, @@ -2113,10 +2463,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateHostnameConfig">( operationDefinitions.v1UpdateHostnameConfig, input, + options, ); }), updateJitAccess: ( input: typeof operationDefinitions.v1UpdateJitAccess.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateJitAccess.outputSchema.Type, SupabaseApiError, @@ -2127,10 +2479,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateJitAccess">( operationDefinitions.v1UpdateJitAccess, input, + options, ); }), updateJitAccessConfig: ( input: typeof operationDefinitions.v1UpdateJitAccessConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateJitAccessConfig.outputSchema.Type, SupabaseApiError, @@ -2141,10 +2495,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateJitAccessConfig">( operationDefinitions.v1UpdateJitAccessConfig, input, + options, ); }), updateNetworkRestrictions: ( input: typeof operationDefinitions.v1UpdateNetworkRestrictions.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateNetworkRestrictions.outputSchema.Type, SupabaseApiError, @@ -2155,10 +2511,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateNetworkRestrictions">( operationDefinitions.v1UpdateNetworkRestrictions, input, + options, ); }), updatePgsodiumConfig: ( input: typeof operationDefinitions.v1UpdatePgsodiumConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdatePgsodiumConfig.outputSchema.Type, SupabaseApiError, @@ -2169,10 +2527,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdatePgsodiumConfig">( operationDefinitions.v1UpdatePgsodiumConfig, input, + options, ); }), updatePoolerConfig: ( input: typeof operationDefinitions.v1UpdatePoolerConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdatePoolerConfig.outputSchema.Type, SupabaseApiError, @@ -2183,10 +2543,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdatePoolerConfig">( operationDefinitions.v1UpdatePoolerConfig, input, + options, ); }), updatePostgresConfig: ( input: typeof operationDefinitions.v1UpdatePostgresConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdatePostgresConfig.outputSchema.Type, SupabaseApiError, @@ -2197,10 +2559,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdatePostgresConfig">( operationDefinitions.v1UpdatePostgresConfig, input, + options, ); }), updatePostgrestServiceConfig: ( input: typeof operationDefinitions.v1UpdatePostgrestServiceConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdatePostgrestServiceConfig.outputSchema.Type, SupabaseApiError, @@ -2211,10 +2575,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdatePostgrestServiceConfig">( operationDefinitions.v1UpdatePostgrestServiceConfig, input, + options, ); }), updateProjectApiKey: ( input: typeof operationDefinitions.v1UpdateProjectApiKey.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateProjectApiKey.outputSchema.Type, SupabaseApiError, @@ -2225,10 +2591,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateProjectApiKey">( operationDefinitions.v1UpdateProjectApiKey, input, + options, ); }), updateProjectLegacyApiKeys: ( input: typeof operationDefinitions.v1UpdateProjectLegacyApiKeys.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateProjectLegacyApiKeys.outputSchema.Type, SupabaseApiError, @@ -2239,10 +2607,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateProjectLegacyApiKeys">( operationDefinitions.v1UpdateProjectLegacyApiKeys, input, + options, ); }), updateProjectSigningKey: ( input: typeof operationDefinitions.v1UpdateProjectSigningKey.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateProjectSigningKey.outputSchema.Type, SupabaseApiError, @@ -2253,10 +2623,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateProjectSigningKey">( operationDefinitions.v1UpdateProjectSigningKey, input, + options, ); }), updateRealtimeConfig: ( input: typeof operationDefinitions.v1UpdateRealtimeConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateRealtimeConfig.outputSchema.Type, SupabaseApiError, @@ -2267,10 +2639,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateRealtimeConfig">( operationDefinitions.v1UpdateRealtimeConfig, input, + options, ); }), updateSslEnforcementConfig: ( input: typeof operationDefinitions.v1UpdateSslEnforcementConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateSslEnforcementConfig.outputSchema.Type, SupabaseApiError, @@ -2281,10 +2655,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateSslEnforcementConfig">( operationDefinitions.v1UpdateSslEnforcementConfig, input, + options, ); }), updateStorageConfig: ( input: typeof operationDefinitions.v1UpdateStorageConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpdateStorageConfig.outputSchema.Type, SupabaseApiError, @@ -2295,10 +2671,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpdateStorageConfig">( operationDefinitions.v1UpdateStorageConfig, input, + options, ); }), upgradePostgresVersion: ( input: typeof operationDefinitions.v1UpgradePostgresVersion.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpgradePostgresVersion.outputSchema.Type, SupabaseApiError, @@ -2309,10 +2687,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpgradePostgresVersion">( operationDefinitions.v1UpgradePostgresVersion, input, + options, ); }), upsertAMigration: ( input: typeof operationDefinitions.v1UpsertAMigration.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1UpsertAMigration.outputSchema.Type, SupabaseApiError, @@ -2323,10 +2703,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v1UpsertAMigration">( operationDefinitions.v1UpsertAMigration, input, + options, ); }), verifyDnsConfig: ( input: typeof operationDefinitions.v1VerifyDnsConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v1VerifyDnsConfig.outputSchema.Type, SupabaseApiError, @@ -2337,12 +2719,14 @@ export const versionedEffectOperations = { return yield* client.execute<"v1VerifyDnsConfig">( operationDefinitions.v1VerifyDnsConfig, input, + options, ); }), }, v2: { assignOrganizationMemberRole: ( input: typeof operationDefinitions.v2AssignOrganizationMemberRole.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2AssignOrganizationMemberRole.outputSchema.Type, SupabaseApiError, @@ -2353,10 +2737,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2AssignOrganizationMemberRole">( operationDefinitions.v2AssignOrganizationMemberRole, input, + options, ); }), createABranch: ( input: typeof operationDefinitions.v2CreateABranch.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2CreateABranch.outputSchema.Type, SupabaseApiError, @@ -2367,10 +2753,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2CreateABranch">( operationDefinitions.v2CreateABranch, input, + options, ); }), createComputeInstanceUpload: ( input: typeof operationDefinitions.v2CreateComputeInstanceUpload.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2CreateComputeInstanceUpload.outputSchema.Type, SupabaseApiError, @@ -2381,10 +2769,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2CreateComputeInstanceUpload">( operationDefinitions.v2CreateComputeInstanceUpload, input, + options, ); }), createLogDrain: ( input: typeof operationDefinitions.v2CreateLogDrain.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2CreateLogDrain.outputSchema.Type, SupabaseApiError, @@ -2395,10 +2785,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2CreateLogDrain">( operationDefinitions.v2CreateLogDrain, input, + options, ); }), createNotebook: ( input: typeof operationDefinitions.v2CreateNotebook.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2CreateNotebook.outputSchema.Type, SupabaseApiError, @@ -2409,10 +2801,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2CreateNotebook">( operationDefinitions.v2CreateNotebook, input, + options, ); }), createOrganizationInvitations: ( input: typeof operationDefinitions.v2CreateOrganizationInvitations.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2CreateOrganizationInvitations.outputSchema.Type, SupabaseApiError, @@ -2423,10 +2817,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2CreateOrganizationInvitations">( operationDefinitions.v2CreateOrganizationInvitations, input, + options, ); }), createPrivateLinkAssociation: ( input: typeof operationDefinitions.v2CreatePrivateLinkAssociation.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2CreatePrivateLinkAssociation.outputSchema.Type, SupabaseApiError, @@ -2437,10 +2833,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2CreatePrivateLinkAssociation">( operationDefinitions.v2CreatePrivateLinkAssociation, input, + options, ); }), deleteAComputeInstance: ( input: typeof operationDefinitions.v2DeleteAComputeInstance.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2DeleteAComputeInstance.outputSchema.Type, SupabaseApiError, @@ -2451,10 +2849,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2DeleteAComputeInstance">( operationDefinitions.v2DeleteAComputeInstance, input, + options, ); }), deleteLogDrain: ( input: typeof operationDefinitions.v2DeleteLogDrain.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2DeleteLogDrain.outputSchema.Type, SupabaseApiError, @@ -2465,10 +2865,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2DeleteLogDrain">( operationDefinitions.v2DeleteLogDrain, input, + options, ); }), deleteNotebook: ( input: typeof operationDefinitions.v2DeleteNotebook.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2DeleteNotebook.outputSchema.Type, SupabaseApiError, @@ -2479,10 +2881,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2DeleteNotebook">( operationDefinitions.v2DeleteNotebook, input, + options, ); }), deleteOrganizationInvitations: ( input: typeof operationDefinitions.v2DeleteOrganizationInvitations.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2DeleteOrganizationInvitations.outputSchema.Type, SupabaseApiError, @@ -2493,10 +2897,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2DeleteOrganizationInvitations">( operationDefinitions.v2DeleteOrganizationInvitations, input, + options, ); }), deletePrivateLinkAssociation: ( input: typeof operationDefinitions.v2DeletePrivateLinkAssociation.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2DeletePrivateLinkAssociation.outputSchema.Type, SupabaseApiError, @@ -2507,10 +2913,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2DeletePrivateLinkAssociation">( operationDefinitions.v2DeletePrivateLinkAssociation, input, + options, ); }), deletePrivateLinkAssociationForDatabase: ( input: typeof operationDefinitions.v2DeletePrivateLinkAssociationForDatabase.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2DeletePrivateLinkAssociationForDatabase.outputSchema.Type, SupabaseApiError, @@ -2521,10 +2929,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2DeletePrivateLinkAssociationForDatabase">( operationDefinitions.v2DeletePrivateLinkAssociationForDatabase, input, + options, ); }), deployAComputeInstance: ( input: typeof operationDefinitions.v2DeployAComputeInstance.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2DeployAComputeInstance.outputSchema.Type, SupabaseApiError, @@ -2535,10 +2945,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2DeployAComputeInstance">( operationDefinitions.v2DeployAComputeInstance, input, + options, ); }), getAComputeInstance: ( input: typeof operationDefinitions.v2GetAComputeInstance.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2GetAComputeInstance.outputSchema.Type, SupabaseApiError, @@ -2549,10 +2961,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2GetAComputeInstance">( operationDefinitions.v2GetAComputeInstance, input, + options, ); }), getNotebook: ( input: typeof operationDefinitions.v2GetNotebook.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2GetNotebook.outputSchema.Type, SupabaseApiError, @@ -2560,10 +2974,15 @@ export const versionedEffectOperations = { > => Effect.gen(function* () { const client = yield* SupabaseApiClient; - return yield* client.execute<"v2GetNotebook">(operationDefinitions.v2GetNotebook, input); + return yield* client.execute<"v2GetNotebook">( + operationDefinitions.v2GetNotebook, + input, + options, + ); }), getProjectConfig: ( input: typeof operationDefinitions.v2GetProjectConfig.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2GetProjectConfig.outputSchema.Type, SupabaseApiError, @@ -2574,10 +2993,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2GetProjectConfig">( operationDefinitions.v2GetProjectConfig, input, + options, ); }), listAllComputeInstances: ( input: typeof operationDefinitions.v2ListAllComputeInstances.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2ListAllComputeInstances.outputSchema.Type, SupabaseApiError, @@ -2588,10 +3009,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2ListAllComputeInstances">( operationDefinitions.v2ListAllComputeInstances, input, + options, ); }), listLogDrains: ( input: typeof operationDefinitions.v2ListLogDrains.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2ListLogDrains.outputSchema.Type, SupabaseApiError, @@ -2602,10 +3025,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2ListLogDrains">( operationDefinitions.v2ListLogDrains, input, + options, ); }), listNotebooks: ( input: typeof operationDefinitions.v2ListNotebooks.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2ListNotebooks.outputSchema.Type, SupabaseApiError, @@ -2616,10 +3041,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2ListNotebooks">( operationDefinitions.v2ListNotebooks, input, + options, ); }), listOrganizationGithubConnections: ( input: typeof operationDefinitions.v2ListOrganizationGithubConnections.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2ListOrganizationGithubConnections.outputSchema.Type, SupabaseApiError, @@ -2630,10 +3057,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2ListOrganizationGithubConnections">( operationDefinitions.v2ListOrganizationGithubConnections, input, + options, ); }), listOrganizationMembers: ( input: typeof operationDefinitions.v2ListOrganizationMembers.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2ListOrganizationMembers.outputSchema.Type, SupabaseApiError, @@ -2644,10 +3073,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2ListOrganizationMembers">( operationDefinitions.v2ListOrganizationMembers, input, + options, ); }), listOrganizationProjects: ( input: typeof operationDefinitions.v2ListOrganizationProjects.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2ListOrganizationProjects.outputSchema.Type, SupabaseApiError, @@ -2658,10 +3089,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2ListOrganizationProjects">( operationDefinitions.v2ListOrganizationProjects, input, + options, ); }), listOrganizationRoles: ( input: typeof operationDefinitions.v2ListOrganizationRoles.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2ListOrganizationRoles.outputSchema.Type, SupabaseApiError, @@ -2672,10 +3105,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2ListOrganizationRoles">( operationDefinitions.v2ListOrganizationRoles, input, + options, ); }), listPrivateLinkAssociations: ( input: typeof operationDefinitions.v2ListPrivateLinkAssociations.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2ListPrivateLinkAssociations.outputSchema.Type, SupabaseApiError, @@ -2686,10 +3121,172 @@ export const versionedEffectOperations = { return yield* client.execute<"v2ListPrivateLinkAssociations">( operationDefinitions.v2ListPrivateLinkAssociations, input, + options, + ); + }), + organizationsSlugWebhooksDeliveriesIdGet: ( + input: typeof operationDefinitions.v2OrganizationsSlugWebhooksDeliveriesIdGet.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2OrganizationsSlugWebhooksDeliveriesIdGet.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2OrganizationsSlugWebhooksDeliveriesIdGet">( + operationDefinitions.v2OrganizationsSlugWebhooksDeliveriesIdGet, + input, + options, + ); + }), + organizationsSlugWebhooksDeliveriesIdRetryPost: ( + input: typeof operationDefinitions.v2OrganizationsSlugWebhooksDeliveriesIdRetryPost.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2OrganizationsSlugWebhooksDeliveriesIdRetryPost.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2OrganizationsSlugWebhooksDeliveriesIdRetryPost">( + operationDefinitions.v2OrganizationsSlugWebhooksDeliveriesIdRetryPost, + input, + options, + ); + }), + organizationsSlugWebhooksEndpointsDelete: ( + input: typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsDelete.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsDelete.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2OrganizationsSlugWebhooksEndpointsDelete">( + operationDefinitions.v2OrganizationsSlugWebhooksEndpointsDelete, + input, + options, + ); + }), + organizationsSlugWebhooksEndpointsGet: ( + input: typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsGet.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsGet.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2OrganizationsSlugWebhooksEndpointsGet">( + operationDefinitions.v2OrganizationsSlugWebhooksEndpointsGet, + input, + options, + ); + }), + organizationsSlugWebhooksEndpointsIdDelete: ( + input: typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdDelete.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdDelete.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2OrganizationsSlugWebhooksEndpointsIdDelete">( + operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdDelete, + input, + options, + ); + }), + organizationsSlugWebhooksEndpointsIdDeliveriesGet: ( + input: typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdDeliveriesGet.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdDeliveriesGet.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2OrganizationsSlugWebhooksEndpointsIdDeliveriesGet">( + operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdDeliveriesGet, + input, + options, + ); + }), + organizationsSlugWebhooksEndpointsIdGet: ( + input: typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdGet.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdGet.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2OrganizationsSlugWebhooksEndpointsIdGet">( + operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdGet, + input, + options, + ); + }), + organizationsSlugWebhooksEndpointsIdPatch: ( + input: typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdPatch.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdPatch.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2OrganizationsSlugWebhooksEndpointsIdPatch">( + operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdPatch, + input, + options, + ); + }), + organizationsSlugWebhooksEndpointsIdTestPost: ( + input: typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdTestPost.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdTestPost.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2OrganizationsSlugWebhooksEndpointsIdTestPost">( + operationDefinitions.v2OrganizationsSlugWebhooksEndpointsIdTestPost, + input, + options, + ); + }), + organizationsSlugWebhooksEndpointsPost: ( + input: typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsPost.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2OrganizationsSlugWebhooksEndpointsPost.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2OrganizationsSlugWebhooksEndpointsPost">( + operationDefinitions.v2OrganizationsSlugWebhooksEndpointsPost, + input, + options, ); }), previewAProjectTransfer: ( input: typeof operationDefinitions.v2PreviewAProjectTransfer.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2PreviewAProjectTransfer.outputSchema.Type, SupabaseApiError, @@ -2700,10 +3297,172 @@ export const versionedEffectOperations = { return yield* client.execute<"v2PreviewAProjectTransfer">( operationDefinitions.v2PreviewAProjectTransfer, input, + options, + ); + }), + projectsRefWebhooksDeliveriesIdGet: ( + input: typeof operationDefinitions.v2ProjectsRefWebhooksDeliveriesIdGet.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2ProjectsRefWebhooksDeliveriesIdGet.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ProjectsRefWebhooksDeliveriesIdGet">( + operationDefinitions.v2ProjectsRefWebhooksDeliveriesIdGet, + input, + options, + ); + }), + projectsRefWebhooksDeliveriesIdRetryPost: ( + input: typeof operationDefinitions.v2ProjectsRefWebhooksDeliveriesIdRetryPost.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2ProjectsRefWebhooksDeliveriesIdRetryPost.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ProjectsRefWebhooksDeliveriesIdRetryPost">( + operationDefinitions.v2ProjectsRefWebhooksDeliveriesIdRetryPost, + input, + options, + ); + }), + projectsRefWebhooksEndpointsDelete: ( + input: typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsDelete.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsDelete.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ProjectsRefWebhooksEndpointsDelete">( + operationDefinitions.v2ProjectsRefWebhooksEndpointsDelete, + input, + options, + ); + }), + projectsRefWebhooksEndpointsGet: ( + input: typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsGet.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsGet.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ProjectsRefWebhooksEndpointsGet">( + operationDefinitions.v2ProjectsRefWebhooksEndpointsGet, + input, + options, + ); + }), + projectsRefWebhooksEndpointsIdDelete: ( + input: typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsIdDelete.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsIdDelete.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ProjectsRefWebhooksEndpointsIdDelete">( + operationDefinitions.v2ProjectsRefWebhooksEndpointsIdDelete, + input, + options, + ); + }), + projectsRefWebhooksEndpointsIdDeliveriesGet: ( + input: typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsIdDeliveriesGet.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsIdDeliveriesGet.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ProjectsRefWebhooksEndpointsIdDeliveriesGet">( + operationDefinitions.v2ProjectsRefWebhooksEndpointsIdDeliveriesGet, + input, + options, + ); + }), + projectsRefWebhooksEndpointsIdGet: ( + input: typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsIdGet.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsIdGet.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ProjectsRefWebhooksEndpointsIdGet">( + operationDefinitions.v2ProjectsRefWebhooksEndpointsIdGet, + input, + options, + ); + }), + projectsRefWebhooksEndpointsIdPatch: ( + input: typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsIdPatch.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsIdPatch.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ProjectsRefWebhooksEndpointsIdPatch">( + operationDefinitions.v2ProjectsRefWebhooksEndpointsIdPatch, + input, + options, + ); + }), + projectsRefWebhooksEndpointsIdTestPost: ( + input: typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsIdTestPost.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsIdTestPost.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ProjectsRefWebhooksEndpointsIdTestPost">( + operationDefinitions.v2ProjectsRefWebhooksEndpointsIdTestPost, + input, + options, + ); + }), + projectsRefWebhooksEndpointsPost: ( + input: typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsPost.inputSchema.Type, + options?: SupabaseApiRequestOptions, + ): Effect.Effect< + typeof operationDefinitions.v2ProjectsRefWebhooksEndpointsPost.outputSchema.Type, + SupabaseApiError, + SupabaseApiClient + > => + Effect.gen(function* () { + const client = yield* SupabaseApiClient; + return yield* client.execute<"v2ProjectsRefWebhooksEndpointsPost">( + operationDefinitions.v2ProjectsRefWebhooksEndpointsPost, + input, + options, ); }), runProjectAdvisors: ( input: typeof operationDefinitions.v2RunProjectAdvisors.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2RunProjectAdvisors.outputSchema.Type, SupabaseApiError, @@ -2714,10 +3473,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2RunProjectAdvisors">( operationDefinitions.v2RunProjectAdvisors, input, + options, ); }), transferAProject: ( input: typeof operationDefinitions.v2TransferAProject.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2TransferAProject.outputSchema.Type, SupabaseApiError, @@ -2728,10 +3489,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2TransferAProject">( operationDefinitions.v2TransferAProject, input, + options, ); }), updateLogDrain: ( input: typeof operationDefinitions.v2UpdateLogDrain.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2UpdateLogDrain.outputSchema.Type, SupabaseApiError, @@ -2742,10 +3505,12 @@ export const versionedEffectOperations = { return yield* client.execute<"v2UpdateLogDrain">( operationDefinitions.v2UpdateLogDrain, input, + options, ); }), updateNotebook: ( input: typeof operationDefinitions.v2UpdateNotebook.inputSchema.Type, + options?: SupabaseApiRequestOptions, ): Effect.Effect< typeof operationDefinitions.v2UpdateNotebook.outputSchema.Type, SupabaseApiError, @@ -2756,821 +3521,10 @@ export const versionedEffectOperations = { return yield* client.execute<"v2UpdateNotebook">( operationDefinitions.v2UpdateNotebook, input, + options, ); }), }, } as const; export type GeneratedEffectOperations = typeof versionedEffectOperations; -type GeneratedApiClient = EffectClient; - -export function executeApiClientOperation( - operationId: keyof typeof operationDefinitions, - api: GeneratedApiClient, - input: unknown, -) { - switch (operationId) { - case "v1AcceptInviteExternalJitAccess": - return Schema.decodeUnknownEffect( - operationDefinitions.v1AcceptInviteExternalJitAccess.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.acceptInviteExternalJitAccess(decoded))); - case "v1ActivateCustomHostname": - return Schema.decodeUnknownEffect(operationDefinitions.v1ActivateCustomHostname.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.activateCustomHostname(decoded))); - case "v1ActivateVanitySubdomainConfig": - return Schema.decodeUnknownEffect( - operationDefinitions.v1ActivateVanitySubdomainConfig.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.activateVanitySubdomainConfig(decoded))); - case "v1ApplyAMigration": - return Schema.decodeUnknownEffect(operationDefinitions.v1ApplyAMigration.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.applyAMigration(decoded))); - case "v1ApplyProjectAddon": - return Schema.decodeUnknownEffect(operationDefinitions.v1ApplyProjectAddon.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.applyProjectAddon(decoded))); - case "v1AuthorizeJitAccess": - return Schema.decodeUnknownEffect(operationDefinitions.v1AuthorizeJitAccess.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.authorizeJitAccess(decoded))); - case "v1AuthorizeUser": - return Schema.decodeUnknownEffect(operationDefinitions.v1AuthorizeUser.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.authorizeUser(decoded))); - case "v1BulkCreateSecrets": - return Schema.decodeUnknownEffect(operationDefinitions.v1BulkCreateSecrets.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.bulkCreateSecrets(decoded))); - case "v1BulkDeleteSecrets": - return Schema.decodeUnknownEffect(operationDefinitions.v1BulkDeleteSecrets.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.bulkDeleteSecrets(decoded))); - case "v1BulkUpdateFunctions": - return Schema.decodeUnknownEffect(operationDefinitions.v1BulkUpdateFunctions.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.bulkUpdateFunctions(decoded))); - case "v1CancelAProjectRestoration": - return Schema.decodeUnknownEffect( - operationDefinitions.v1CancelAProjectRestoration.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.cancelAProjectRestoration(decoded))); - case "v1CheckVanitySubdomainAvailability": - return Schema.decodeUnknownEffect( - operationDefinitions.v1CheckVanitySubdomainAvailability.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.checkVanitySubdomainAvailability(decoded))); - case "v1ClaimProjectForOrganization": - return Schema.decodeUnknownEffect( - operationDefinitions.v1ClaimProjectForOrganization.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.claimProjectForOrganization(decoded))); - case "v1CountActionRuns": - return Schema.decodeUnknownEffect(operationDefinitions.v1CountActionRuns.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.countActionRuns(decoded))); - case "v1CreateABranch": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateABranch.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createABranch(decoded))); - case "v1CreateAFunction": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateAFunction.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createAFunction(decoded))); - case "v1CreateAProject": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateAProject.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createAProject(decoded))); - case "v1CreateASsoProvider": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateASsoProvider.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createASsoProvider(decoded))); - case "v1CreateAnOrganization": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateAnOrganization.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createAnOrganization(decoded))); - case "v1CreateLegacySigningKey": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateLegacySigningKey.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createLegacySigningKey(decoded))); - case "v1CreateLoginRole": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateLoginRole.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createLoginRole(decoded))); - case "v1CreateProjectApiKey": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateProjectApiKey.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createProjectApiKey(decoded))); - case "v1CreateProjectClaimToken": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateProjectClaimToken.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createProjectClaimToken(decoded))); - case "v1CreateProjectSigningKey": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateProjectSigningKey.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createProjectSigningKey(decoded))); - case "v1CreateProjectTpaIntegration": - return Schema.decodeUnknownEffect( - operationDefinitions.v1CreateProjectTpaIntegration.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.createProjectTpaIntegration(decoded))); - case "v1CreateRestorePoint": - return Schema.decodeUnknownEffect(operationDefinitions.v1CreateRestorePoint.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.createRestorePoint(decoded))); - case "v1DeactivateVanitySubdomainConfig": - return Schema.decodeUnknownEffect( - operationDefinitions.v1DeactivateVanitySubdomainConfig.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.deactivateVanitySubdomainConfig(decoded))); - case "v1DeleteHostnameConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeleteHostnameConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deleteHostnameConfig(decoded))); - case "v1DeleteABranch": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeleteABranch.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deleteABranch(decoded))); - case "v1DeleteAFunction": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeleteAFunction.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deleteAFunction(decoded))); - case "v1DeleteAProject": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeleteAProject.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deleteAProject(decoded))); - case "v1DeleteASsoProvider": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeleteASsoProvider.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deleteASsoProvider(decoded))); - case "v1DeleteInviteExternalJitAccess": - return Schema.decodeUnknownEffect( - operationDefinitions.v1DeleteInviteExternalJitAccess.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.deleteInviteExternalJitAccess(decoded))); - case "v1DeleteJitAccess": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeleteJitAccess.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deleteJitAccess(decoded))); - case "v1DeleteLoginRoles": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeleteLoginRoles.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deleteLoginRoles(decoded))); - case "v1DeleteNetworkBans": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeleteNetworkBans.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deleteNetworkBans(decoded))); - case "v1DeleteProjectApiKey": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeleteProjectApiKey.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deleteProjectApiKey(decoded))); - case "v1DeleteProjectClaimToken": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeleteProjectClaimToken.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deleteProjectClaimToken(decoded))); - case "v1DeleteProjectTpaIntegration": - return Schema.decodeUnknownEffect( - operationDefinitions.v1DeleteProjectTpaIntegration.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.deleteProjectTpaIntegration(decoded))); - case "v1DeployAFunction": - return Schema.decodeUnknownEffect(operationDefinitions.v1DeployAFunction.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.deployAFunction(decoded))); - case "v1DiffABranch": - return Schema.decodeUnknownEffect(operationDefinitions.v1DiffABranch.inputSchema)(input).pipe( - Effect.flatMap((decoded) => api.v1.diffABranch(decoded)), - ); - case "v1DisablePreviewBranching": - return Schema.decodeUnknownEffect(operationDefinitions.v1DisablePreviewBranching.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.disablePreviewBranching(decoded))); - case "v1DisableReadonlyModeTemporarily": - return Schema.decodeUnknownEffect( - operationDefinitions.v1DisableReadonlyModeTemporarily.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.disableReadonlyModeTemporarily(decoded))); - case "v1EnableDatabaseWebhook": - return Schema.decodeUnknownEffect(operationDefinitions.v1EnableDatabaseWebhook.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.enableDatabaseWebhook(decoded))); - case "v1ExchangeOauthToken": - return Schema.decodeUnknownEffect(operationDefinitions.v1ExchangeOauthToken.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.exchangeOauthToken(decoded))); - case "v1GenerateTypescriptTypes": - return Schema.decodeUnknownEffect(operationDefinitions.v1GenerateTypescriptTypes.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.generateTypescriptTypes(decoded))); - case "v1GetABranch": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetABranch.inputSchema)(input).pipe( - Effect.flatMap((decoded) => api.v1.getABranch(decoded)), - ); - case "v1GetABranchConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetABranchConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getABranchConfig(decoded))); - case "v1GetAFunction": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetAFunction.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getAFunction(decoded))); - case "v1GetAFunctionBody": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetAFunctionBody.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getAFunctionBody(decoded))); - case "v1GetAMigration": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetAMigration.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getAMigration(decoded))); - case "v1GetASnippet": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetASnippet.inputSchema)(input).pipe( - Effect.flatMap((decoded) => api.v1.getASnippet(decoded)), - ); - case "v1GetASsoProvider": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetASsoProvider.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getASsoProvider(decoded))); - case "v1GetActionRun": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetActionRun.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getActionRun(decoded))); - case "v1GetActionRunLogs": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetActionRunLogs.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getActionRunLogs(decoded))); - case "v1GetAllProjectsForOrganization": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetAllProjectsForOrganization.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getAllProjectsForOrganization(decoded))); - case "v1GetAnOrganization": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetAnOrganization.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getAnOrganization(decoded))); - case "v1GetAuthServiceConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetAuthServiceConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getAuthServiceConfig(decoded))); - case "v1GetAvailableRegions": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetAvailableRegions.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getAvailableRegions(decoded))); - case "v1GetBackupSchedule": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetBackupSchedule.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getBackupSchedule(decoded))); - case "v1GetDatabaseDisk": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetDatabaseDisk.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getDatabaseDisk(decoded))); - case "v1GetDatabaseMetadata": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetDatabaseMetadata.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getDatabaseMetadata(decoded))); - case "v1GetDatabaseOpenapi": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetDatabaseOpenapi.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getDatabaseOpenapi(decoded))); - case "v1GetDiskUtilization": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetDiskUtilization.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getDiskUtilization(decoded))); - case "v1GetHostnameConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetHostnameConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getHostnameConfig(decoded))); - case "v1GetJitAccess": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetJitAccess.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getJitAccess(decoded))); - case "v1GetJitAccessConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetJitAccessConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getJitAccessConfig(decoded))); - case "v1GetLegacySigningKey": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetLegacySigningKey.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getLegacySigningKey(decoded))); - case "v1GetNetworkRestrictions": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetNetworkRestrictions.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getNetworkRestrictions(decoded))); - case "v1GetOrganizationEntitlements": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetOrganizationEntitlements.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getOrganizationEntitlements(decoded))); - case "v1GetOrganizationProjectClaim": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetOrganizationProjectClaim.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getOrganizationProjectClaim(decoded))); - case "v1GetPerformanceAdvisors": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetPerformanceAdvisors.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getPerformanceAdvisors(decoded))); - case "v1GetPgsodiumConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetPgsodiumConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getPgsodiumConfig(decoded))); - case "v1GetPoolerConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetPoolerConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getPoolerConfig(decoded))); - case "v1GetPostgresConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetPostgresConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getPostgresConfig(decoded))); - case "v1GetPostgresUpgradeEligibility": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetPostgresUpgradeEligibility.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getPostgresUpgradeEligibility(decoded))); - case "v1GetPostgresUpgradeStatus": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetPostgresUpgradeStatus.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getPostgresUpgradeStatus(decoded))); - case "v1GetPostgrestServiceConfig": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetPostgrestServiceConfig.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getPostgrestServiceConfig(decoded))); - case "v1GetProfile": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProfile.inputSchema)(input).pipe( - Effect.flatMap((_decoded) => api.v1.getProfile()), - ); - case "v1GetProject": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProject.inputSchema)(input).pipe( - Effect.flatMap((decoded) => api.v1.getProject(decoded)), - ); - case "v1GetProjectApiKey": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProjectApiKey.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getProjectApiKey(decoded))); - case "v1GetProjectApiKeys": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProjectApiKeys.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getProjectApiKeys(decoded))); - case "v1GetProjectClaimToken": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProjectClaimToken.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getProjectClaimToken(decoded))); - case "v1GetProjectDiskAutoscaleConfig": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetProjectDiskAutoscaleConfig.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getProjectDiskAutoscaleConfig(decoded))); - case "v1GetProjectFunctionCombinedStats": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetProjectFunctionCombinedStats.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getProjectFunctionCombinedStats(decoded))); - case "v1GetProjectLegacyApiKeys": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProjectLegacyApiKeys.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getProjectLegacyApiKeys(decoded))); - case "v1GetProjectLogs": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProjectLogs.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getProjectLogs(decoded))); - case "v1GetProjectLogsAll": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProjectLogsAll.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getProjectLogsAll(decoded))); - case "v1GetProjectPgbouncerConfig": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetProjectPgbouncerConfig.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getProjectPgbouncerConfig(decoded))); - case "v1GetProjectSigningKey": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProjectSigningKey.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getProjectSigningKey(decoded))); - case "v1GetProjectSigningKeys": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProjectSigningKeys.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getProjectSigningKeys(decoded))); - case "v1GetProjectTpaIntegration": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetProjectTpaIntegration.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getProjectTpaIntegration(decoded))); - case "v1GetProjectUsageApiCount": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetProjectUsageApiCount.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getProjectUsageApiCount(decoded))); - case "v1GetProjectUsageRequestCount": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetProjectUsageRequestCount.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getProjectUsageRequestCount(decoded))); - case "v1GetReadonlyModeStatus": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetReadonlyModeStatus.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getReadonlyModeStatus(decoded))); - case "v1GetRealtimeConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetRealtimeConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getRealtimeConfig(decoded))); - case "v1GetRestorePoint": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetRestorePoint.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getRestorePoint(decoded))); - case "v1GetSecurityAdvisors": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetSecurityAdvisors.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getSecurityAdvisors(decoded))); - case "v1GetServicesHealth": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetServicesHealth.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getServicesHealth(decoded))); - case "v1GetSslEnforcementConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetSslEnforcementConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getSslEnforcementConfig(decoded))); - case "v1GetStorageConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1GetStorageConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.getStorageConfig(decoded))); - case "v1GetVanitySubdomainConfig": - return Schema.decodeUnknownEffect( - operationDefinitions.v1GetVanitySubdomainConfig.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.getVanitySubdomainConfig(decoded))); - case "v1InviteExternalJitAccess": - return Schema.decodeUnknownEffect(operationDefinitions.v1InviteExternalJitAccess.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.inviteExternalJitAccess(decoded))); - case "v1ListActionRuns": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListActionRuns.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listActionRuns(decoded))); - case "v1ListAllBackups": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListAllBackups.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listAllBackups(decoded))); - case "v1ListAllBranches": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListAllBranches.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listAllBranches(decoded))); - case "v1ListAllBuckets": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListAllBuckets.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listAllBuckets(decoded))); - case "v1ListAllFunctions": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListAllFunctions.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listAllFunctions(decoded))); - case "v1ListAllNetworkBans": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListAllNetworkBans.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listAllNetworkBans(decoded))); - case "v1ListAllNetworkBansEnriched": - return Schema.decodeUnknownEffect( - operationDefinitions.v1ListAllNetworkBansEnriched.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.listAllNetworkBansEnriched(decoded))); - case "v1ListAllOrganizations": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListAllOrganizations.inputSchema)( - input, - ).pipe(Effect.flatMap((_decoded) => api.v1.listAllOrganizations())); - case "v1ListAllProjects": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListAllProjects.inputSchema)( - input, - ).pipe(Effect.flatMap((_decoded) => api.v1.listAllProjects())); - case "v1ListAllSecrets": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListAllSecrets.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listAllSecrets(decoded))); - case "v1ListAllSnippets": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListAllSnippets.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listAllSnippets(decoded))); - case "v1ListAllSsoProvider": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListAllSsoProvider.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listAllSsoProvider(decoded))); - case "v1ListAvailableRestoreVersions": - return Schema.decodeUnknownEffect( - operationDefinitions.v1ListAvailableRestoreVersions.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.listAvailableRestoreVersions(decoded))); - case "v1ListJitAccess": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListJitAccess.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listJitAccess(decoded))); - case "v1ListMigrationHistory": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListMigrationHistory.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listMigrationHistory(decoded))); - case "v1ListOrganizationMembers": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListOrganizationMembers.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listOrganizationMembers(decoded))); - case "v1ListProjectAddons": - return Schema.decodeUnknownEffect(operationDefinitions.v1ListProjectAddons.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.listProjectAddons(decoded))); - case "v1ListProjectTpaIntegrations": - return Schema.decodeUnknownEffect( - operationDefinitions.v1ListProjectTpaIntegrations.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.listProjectTpaIntegrations(decoded))); - case "v1MergeABranch": - return Schema.decodeUnknownEffect(operationDefinitions.v1MergeABranch.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.mergeABranch(decoded))); - case "v1ModifyDatabaseDisk": - return Schema.decodeUnknownEffect(operationDefinitions.v1ModifyDatabaseDisk.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.modifyDatabaseDisk(decoded))); - case "v1OauthAuthorizeProjectClaim": - return Schema.decodeUnknownEffect( - operationDefinitions.v1OauthAuthorizeProjectClaim.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.oauthAuthorizeProjectClaim(decoded))); - case "v1PatchAMigration": - return Schema.decodeUnknownEffect(operationDefinitions.v1PatchAMigration.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.patchAMigration(decoded))); - case "v1PatchNetworkRestrictions": - return Schema.decodeUnknownEffect( - operationDefinitions.v1PatchNetworkRestrictions.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.patchNetworkRestrictions(decoded))); - case "v1PauseAProject": - return Schema.decodeUnknownEffect(operationDefinitions.v1PauseAProject.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.pauseAProject(decoded))); - case "v1PushABranch": - return Schema.decodeUnknownEffect(operationDefinitions.v1PushABranch.inputSchema)(input).pipe( - Effect.flatMap((decoded) => api.v1.pushABranch(decoded)), - ); - case "v1ReadOnlyQuery": - return Schema.decodeUnknownEffect(operationDefinitions.v1ReadOnlyQuery.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.readOnlyQuery(decoded))); - case "v1RemoveAReadReplica": - return Schema.decodeUnknownEffect(operationDefinitions.v1RemoveAReadReplica.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.removeAReadReplica(decoded))); - case "v1RemoveProjectAddon": - return Schema.decodeUnknownEffect(operationDefinitions.v1RemoveProjectAddon.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.removeProjectAddon(decoded))); - case "v1RemoveProjectSigningKey": - return Schema.decodeUnknownEffect(operationDefinitions.v1RemoveProjectSigningKey.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.removeProjectSigningKey(decoded))); - case "v1ResetABranch": - return Schema.decodeUnknownEffect(operationDefinitions.v1ResetABranch.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.resetABranch(decoded))); - case "v1RestartAProject": - return Schema.decodeUnknownEffect(operationDefinitions.v1RestartAProject.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.restartAProject(decoded))); - case "v1RestoreABranch": - return Schema.decodeUnknownEffect(operationDefinitions.v1RestoreABranch.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.restoreABranch(decoded))); - case "v1RestoreAProject": - return Schema.decodeUnknownEffect(operationDefinitions.v1RestoreAProject.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.restoreAProject(decoded))); - case "v1RestorePhysicalBackup": - return Schema.decodeUnknownEffect(operationDefinitions.v1RestorePhysicalBackup.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.restorePhysicalBackup(decoded))); - case "v1RestorePitrBackup": - return Schema.decodeUnknownEffect(operationDefinitions.v1RestorePitrBackup.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.restorePitrBackup(decoded))); - case "v1RevokeToken": - return Schema.decodeUnknownEffect(operationDefinitions.v1RevokeToken.inputSchema)(input).pipe( - Effect.flatMap((decoded) => api.v1.revokeToken(decoded)), - ); - case "v1RollbackMigrations": - return Schema.decodeUnknownEffect(operationDefinitions.v1RollbackMigrations.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.rollbackMigrations(decoded))); - case "v1RunAQuery": - return Schema.decodeUnknownEffect(operationDefinitions.v1RunAQuery.inputSchema)(input).pipe( - Effect.flatMap((decoded) => api.v1.runAQuery(decoded)), - ); - case "v1ScrapeProjectMetrics": - return Schema.decodeUnknownEffect(operationDefinitions.v1ScrapeProjectMetrics.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.scrapeProjectMetrics(decoded))); - case "v1SetupAReadReplica": - return Schema.decodeUnknownEffect(operationDefinitions.v1SetupAReadReplica.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.setupAReadReplica(decoded))); - case "v1ShutdownRealtime": - return Schema.decodeUnknownEffect(operationDefinitions.v1ShutdownRealtime.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.shutdownRealtime(decoded))); - case "v1Undo": - return Schema.decodeUnknownEffect(operationDefinitions.v1Undo.inputSchema)(input).pipe( - Effect.flatMap((decoded) => api.v1.undo(decoded)), - ); - case "v1UpdateABranchConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateABranchConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateABranchConfig(decoded))); - case "v1UpdateAFunction": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateAFunction.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateAFunction(decoded))); - case "v1UpdateAProject": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateAProject.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateAProject(decoded))); - case "v1UpdateASsoProvider": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateASsoProvider.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateASsoProvider(decoded))); - case "v1UpdateActionRunStatus": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateActionRunStatus.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateActionRunStatus(decoded))); - case "v1UpdateAuthServiceConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateAuthServiceConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateAuthServiceConfig(decoded))); - case "v1UpdateBackupSchedule": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateBackupSchedule.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateBackupSchedule(decoded))); - case "v1UpdateDatabasePassword": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateDatabasePassword.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateDatabasePassword(decoded))); - case "v1UpdateHostnameConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateHostnameConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateHostnameConfig(decoded))); - case "v1UpdateJitAccess": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateJitAccess.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateJitAccess(decoded))); - case "v1UpdateJitAccessConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateJitAccessConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateJitAccessConfig(decoded))); - case "v1UpdateNetworkRestrictions": - return Schema.decodeUnknownEffect( - operationDefinitions.v1UpdateNetworkRestrictions.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.updateNetworkRestrictions(decoded))); - case "v1UpdatePgsodiumConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdatePgsodiumConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updatePgsodiumConfig(decoded))); - case "v1UpdatePoolerConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdatePoolerConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updatePoolerConfig(decoded))); - case "v1UpdatePostgresConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdatePostgresConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updatePostgresConfig(decoded))); - case "v1UpdatePostgrestServiceConfig": - return Schema.decodeUnknownEffect( - operationDefinitions.v1UpdatePostgrestServiceConfig.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.updatePostgrestServiceConfig(decoded))); - case "v1UpdateProjectApiKey": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateProjectApiKey.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateProjectApiKey(decoded))); - case "v1UpdateProjectLegacyApiKeys": - return Schema.decodeUnknownEffect( - operationDefinitions.v1UpdateProjectLegacyApiKeys.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.updateProjectLegacyApiKeys(decoded))); - case "v1UpdateProjectSigningKey": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateProjectSigningKey.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateProjectSigningKey(decoded))); - case "v1UpdateRealtimeConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateRealtimeConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateRealtimeConfig(decoded))); - case "v1UpdateSslEnforcementConfig": - return Schema.decodeUnknownEffect( - operationDefinitions.v1UpdateSslEnforcementConfig.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v1.updateSslEnforcementConfig(decoded))); - case "v1UpdateStorageConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpdateStorageConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.updateStorageConfig(decoded))); - case "v1UpgradePostgresVersion": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpgradePostgresVersion.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.upgradePostgresVersion(decoded))); - case "v1UpsertAMigration": - return Schema.decodeUnknownEffect(operationDefinitions.v1UpsertAMigration.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.upsertAMigration(decoded))); - case "v1VerifyDnsConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v1VerifyDnsConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v1.verifyDnsConfig(decoded))); - case "v2AssignOrganizationMemberRole": - return Schema.decodeUnknownEffect( - operationDefinitions.v2AssignOrganizationMemberRole.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v2.assignOrganizationMemberRole(decoded))); - case "v2CreateABranch": - return Schema.decodeUnknownEffect(operationDefinitions.v2CreateABranch.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.createABranch(decoded))); - case "v2CreateComputeInstanceUpload": - return Schema.decodeUnknownEffect( - operationDefinitions.v2CreateComputeInstanceUpload.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v2.createComputeInstanceUpload(decoded))); - case "v2CreateLogDrain": - return Schema.decodeUnknownEffect(operationDefinitions.v2CreateLogDrain.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.createLogDrain(decoded))); - case "v2CreateNotebook": - return Schema.decodeUnknownEffect(operationDefinitions.v2CreateNotebook.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.createNotebook(decoded))); - case "v2CreateOrganizationInvitations": - return Schema.decodeUnknownEffect( - operationDefinitions.v2CreateOrganizationInvitations.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v2.createOrganizationInvitations(decoded))); - case "v2CreatePrivateLinkAssociation": - return Schema.decodeUnknownEffect( - operationDefinitions.v2CreatePrivateLinkAssociation.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v2.createPrivateLinkAssociation(decoded))); - case "v2DeleteAComputeInstance": - return Schema.decodeUnknownEffect(operationDefinitions.v2DeleteAComputeInstance.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.deleteAComputeInstance(decoded))); - case "v2DeleteLogDrain": - return Schema.decodeUnknownEffect(operationDefinitions.v2DeleteLogDrain.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.deleteLogDrain(decoded))); - case "v2DeleteNotebook": - return Schema.decodeUnknownEffect(operationDefinitions.v2DeleteNotebook.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.deleteNotebook(decoded))); - case "v2DeleteOrganizationInvitations": - return Schema.decodeUnknownEffect( - operationDefinitions.v2DeleteOrganizationInvitations.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v2.deleteOrganizationInvitations(decoded))); - case "v2DeletePrivateLinkAssociation": - return Schema.decodeUnknownEffect( - operationDefinitions.v2DeletePrivateLinkAssociation.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v2.deletePrivateLinkAssociation(decoded))); - case "v2DeletePrivateLinkAssociationForDatabase": - return Schema.decodeUnknownEffect( - operationDefinitions.v2DeletePrivateLinkAssociationForDatabase.inputSchema, - )(input).pipe( - Effect.flatMap((decoded) => api.v2.deletePrivateLinkAssociationForDatabase(decoded)), - ); - case "v2DeployAComputeInstance": - return Schema.decodeUnknownEffect(operationDefinitions.v2DeployAComputeInstance.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.deployAComputeInstance(decoded))); - case "v2GetAComputeInstance": - return Schema.decodeUnknownEffect(operationDefinitions.v2GetAComputeInstance.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.getAComputeInstance(decoded))); - case "v2GetNotebook": - return Schema.decodeUnknownEffect(operationDefinitions.v2GetNotebook.inputSchema)(input).pipe( - Effect.flatMap((decoded) => api.v2.getNotebook(decoded)), - ); - case "v2GetProjectConfig": - return Schema.decodeUnknownEffect(operationDefinitions.v2GetProjectConfig.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.getProjectConfig(decoded))); - case "v2ListAllComputeInstances": - return Schema.decodeUnknownEffect(operationDefinitions.v2ListAllComputeInstances.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.listAllComputeInstances(decoded))); - case "v2ListLogDrains": - return Schema.decodeUnknownEffect(operationDefinitions.v2ListLogDrains.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.listLogDrains(decoded))); - case "v2ListNotebooks": - return Schema.decodeUnknownEffect(operationDefinitions.v2ListNotebooks.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.listNotebooks(decoded))); - case "v2ListOrganizationGithubConnections": - return Schema.decodeUnknownEffect( - operationDefinitions.v2ListOrganizationGithubConnections.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v2.listOrganizationGithubConnections(decoded))); - case "v2ListOrganizationMembers": - return Schema.decodeUnknownEffect(operationDefinitions.v2ListOrganizationMembers.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.listOrganizationMembers(decoded))); - case "v2ListOrganizationProjects": - return Schema.decodeUnknownEffect( - operationDefinitions.v2ListOrganizationProjects.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v2.listOrganizationProjects(decoded))); - case "v2ListOrganizationRoles": - return Schema.decodeUnknownEffect(operationDefinitions.v2ListOrganizationRoles.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.listOrganizationRoles(decoded))); - case "v2ListPrivateLinkAssociations": - return Schema.decodeUnknownEffect( - operationDefinitions.v2ListPrivateLinkAssociations.inputSchema, - )(input).pipe(Effect.flatMap((decoded) => api.v2.listPrivateLinkAssociations(decoded))); - case "v2PreviewAProjectTransfer": - return Schema.decodeUnknownEffect(operationDefinitions.v2PreviewAProjectTransfer.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.previewAProjectTransfer(decoded))); - case "v2RunProjectAdvisors": - return Schema.decodeUnknownEffect(operationDefinitions.v2RunProjectAdvisors.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.runProjectAdvisors(decoded))); - case "v2TransferAProject": - return Schema.decodeUnknownEffect(operationDefinitions.v2TransferAProject.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.transferAProject(decoded))); - case "v2UpdateLogDrain": - return Schema.decodeUnknownEffect(operationDefinitions.v2UpdateLogDrain.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.updateLogDrain(decoded))); - case "v2UpdateNotebook": - return Schema.decodeUnknownEffect(operationDefinitions.v2UpdateNotebook.inputSchema)( - input, - ).pipe(Effect.flatMap((decoded) => api.v2.updateNotebook(decoded))); - } -} diff --git a/packages/api/src/generated/openapi.json b/packages/api/src/generated/openapi.json index 374fd9a68e..9cc8aaedea 100644 --- a/packages/api/src/generated/openapi.json +++ b/packages/api/src/generated/openapi.json @@ -14583,6 +14583,16922 @@ "x-fga-permissions": [["organization_projects_read"]], "x-oauth-scope": "projects:read" } + }, + "/v2/projects/{ref}/webhooks/endpoints": { + "get": { + "operationId": "v2-projects-ref-webhooks-endpoints-get", + "parameters": [ + { + "in": "path", + "name": "ref", + "schema": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst" + }, + "required": true, + "description": "Project ref" + }, + { + "in": "query", + "name": "page[offset]", + "schema": { + "default": "0", + "type": "string", + "pattern": "^\\d+$" + }, + "description": "Offset for offset-based pagination.\n\nOffset should be dividable by `page[limit]` without a reminder (`offset % limit === 0`), otherwise it will cause weird behavior when used on a frontend." + }, + { + "in": "query", + "name": "page[limit]", + "schema": { + "default": "20", + "type": "string", + "pattern": "^\\d+$" + }, + "description": "Up to how many records to return." + } + ], + "tags": ["Project webhooks"], + "responses": { + "200": { + "description": "Collection of endpoints", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "links": { + "type": "object", + "properties": { + "first": { + "description": "URL path to the first page if available.", + "example": "/v2/organization/slug/webhooks/endpoints?page[limit]=10&page[offset]=0", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "prev": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "URL path to the previous page.", + "example": "/v2/organization/slug/webhooks/endpoints?page[limit]=10&page[offset]=0" + }, + "next": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "URL path to the next page.", + "example": "/v2/organization/slug/webhooks/endpoints?page[limit]=10&page[offset]=20" + }, + "last": { + "description": "URL path to the last page if available.", + "example": "/v2/organization/slug/webhooks/endpoints?page[limit]=10&page[offset]=30", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["prev", "next"] + } + }, + "required": ["data", "links"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "List endpoints", + "description": "List all Webhook endpoints based on a project's ref or an organization's slug." + }, + "post": { + "operationId": "v2-projects-ref-webhooks-endpoints-post", + "parameters": [ + { + "in": "path", + "name": "ref", + "schema": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst" + }, + "required": true, + "description": "Project ref" + } + ], + "tags": ["Project webhooks"], + "responses": { + "201": { + "description": "Created endpoint", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Create endpoint", + "description": "Create new endpoint configuration to subscribe to specific webhook events.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "default": true, + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + "signing_secret": { + "type": "string", + "minLength": 8, + "maxLength": 64, + "description": "Secret key to use when signing. All events will be signed according to the [Standard Webhooks](https://www.standardwebhooks.com/) specification." + } + }, + "required": ["url", "event_types", "signing_secret"] + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + } + } + } + } + }, + "delete": { + "operationId": "v2-projects-ref-webhooks-endpoints-delete", + "parameters": [ + { + "in": "path", + "name": "ref", + "schema": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst" + }, + "required": true, + "description": "Project ref" + } + ], + "tags": ["Project webhooks"], + "responses": { + "200": { + "description": "Deleted endpoints", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Delete all endpoints", + "description": "Delete all endpoints including all events and deliveries.\n\nAny in-flight webhooks will result in a no-op." + } + }, + "/v2/projects/{ref}/webhooks/endpoints/{id}": { + "get": { + "operationId": "v2-projects-ref-webhooks-endpoints-id-get", + "parameters": [ + { + "in": "path", + "name": "ref", + "schema": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst" + }, + "required": true, + "description": "Project ref" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of an endpoint (UUID v7)." + } + ], + "tags": ["Project webhooks"], + "responses": { + "200": { + "description": "Endpoint details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "EndpointNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_not_found" + }, + "message": { + "type": "string", + "const": "Webhook endpoint not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "endpoint_not_found", + "message": "Webhook endpoint not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Get endpoint", + "description": "Get details of a specific endpoint." + }, + "patch": { + "operationId": "v2-projects-ref-webhooks-endpoints-id-patch", + "parameters": [ + { + "in": "path", + "name": "ref", + "schema": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst" + }, + "required": true, + "description": "Project ref" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of an endpoint (UUID v7)." + } + ], + "tags": ["Project webhooks"], + "responses": { + "200": { + "description": "Updated endpoint details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "EndpointNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_not_found" + }, + "message": { + "type": "string", + "const": "Webhook endpoint not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "endpoint_not_found", + "message": "Webhook endpoint not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Update endpoint", + "description": "Update endpoint's configuration.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + "signing_secret": { + "type": "string", + "minLength": 8, + "maxLength": 64, + "description": "Secret key to use when signing. All events will be signed according to the [Standard Webhooks](https://www.standardwebhooks.com/) specification." + } + } + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + } + } + } + } + }, + "delete": { + "operationId": "v2-projects-ref-webhooks-endpoints-id-delete", + "parameters": [ + { + "in": "path", + "name": "ref", + "schema": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst" + }, + "required": true, + "description": "Project ref" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of an endpoint (UUID v7)." + } + ], + "tags": ["Project webhooks"], + "responses": { + "200": { + "description": "Deleted endpoint details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "EndpointNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_not_found" + }, + "message": { + "type": "string", + "const": "Webhook endpoint not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "endpoint_not_found", + "message": "Webhook endpoint not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Delete endpoint", + "description": "Delete the endpoint including all events and deliveries\n\nAny in-flight webhooks will result in a no-op." + } + }, + "/v2/projects/{ref}/webhooks/endpoints/{id}/deliveries": { + "get": { + "operationId": "v2-projects-ref-webhooks-endpoints-id-deliveries-get", + "parameters": [ + { + "in": "path", + "name": "ref", + "schema": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst" + }, + "required": true, + "description": "Project ref" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of an endpoint (UUID v7)." + }, + { + "in": "query", + "name": "page[before]", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "description": "Cursor in cursor-based pagination to return up to `page[size]` records before (exclusive) the entry specified by this query param." + }, + { + "in": "query", + "name": "page[after]", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "description": "Cursor in cursor-based pagination to return up to `page[size]` records after (exclusive) the entry specified by this query param." + }, + { + "in": "query", + "name": "page[size]", + "schema": { + "default": "20", + "type": "string", + "pattern": "^\\d+$" + }, + "description": "Up to how many records to return." + } + ], + "tags": ["Project webhooks"], + "responses": { + "200": { + "description": "List of deliveries", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "delivery", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of a delivery (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of a delivery (UUID v7)." + }, + "event_id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of the event, which triggered the delivery (UUID v7)." + }, + "status": { + "type": "string", + "enum": ["pending", "success", "failure", "skipped"], + "description": "Status of the delivery attempt." + }, + "response_code": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "HTTP status code of the response, `0` if unavailable." + }, + "response_headers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "HTTP headers of the response, `{}` if unavailable." + }, + "response_body": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "Parsed JSON representation of an HTTP body of the response, `{}` if unavailable." + }, + { + "type": "string", + "description": "String representation of an HTTP body of the response." + } + ] + }, + { + "type": "null" + } + ] + }, + "error_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "attempt_timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of the attempt." + }, + "event": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded" + ], + "description": "Webhook event type." + }, + "payload": { + "type": "object", + "properties": { + "organization_slug": { + "type": "string", + "pattern": "^[\\w-]+$", + "description": "Organization slug", + "example": "tsrqponmlkjihgfedcba" + }, + "project_ref": { + "anyOf": [ + { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "description": "Project ref", + "example": "abcdefghijklmnopqrst" + }, + { + "type": "null" + } + ] + } + }, + "required": ["organization_slug", "project_ref"], + "description": "Final data sent to the consumer." + }, + "timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of event publication." + } + }, + "required": ["type", "payload", "timestamp"] + } + }, + "required": [ + "id", + "event_id", + "status", + "response_code", + "response_headers", + "response_body", + "error_code", + "error_message", + "attempt_timestamp", + "event" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "links": { + "type": "object", + "properties": { + "first": { + "description": "URL path to the first page if available.", + "example": "/v2/organizations/slug/webhooks/endpoints/{id}/deliveries?page[size]=10", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "prev": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "URL path to the previous page.", + "example": "/v2/organizations/slug/webhooks/endpoints/{id}/deliveries?page[size]=10&page[before]=019adf7d-4513-74c5-bb9a-f1bc0f7a95d7" + }, + "next": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "URL path to the next page.", + "example": "/v2/organizations/slug/webhooks/endpoints/{id}/deliveries?page[size]=10&page[after]=019adf7d-4513-7062-b292-78b86cc470a4" + }, + "last": { + "description": "URL path to the last page if available.", + "example": "/v2/organizations/slug/webhooks/endpoints/{id}/deliveries?page[size]=10&page[after]=019adf7d-4513-71ba-b264-21900edb4295", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["prev", "next"] + } + }, + "required": ["data", "links"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "EndpointNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_not_found" + }, + "message": { + "type": "string", + "const": "Webhook endpoint not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "endpoint_not_found", + "message": "Webhook endpoint not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "List deliveries", + "description": "List all deliveries for a specific endpoint in descending order (newest first).\n\nDeliveries which has expired are no longer available and will not be listed." + } + }, + "/v2/projects/{ref}/webhooks/endpoints/{id}/test": { + "post": { + "operationId": "v2-projects-ref-webhooks-endpoints-id-test-post", + "parameters": [ + { + "in": "path", + "name": "ref", + "schema": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst" + }, + "required": true, + "description": "Project ref" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of an endpoint (UUID v7)." + } + ], + "tags": ["Project webhooks"], + "responses": { + "201": { + "description": "Event published", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "ingress", + "description": "Resource type." + }, + "id": { + "type": "string" + } + }, + "required": ["type", "id"] + } + }, + "required": ["data"] + } + } + } + }, + "400": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_disabled" + }, + "message": { + "type": "string", + "const": "Webhook endpoint is disabled" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "EndpointTestDisabled" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_event_type_not_subscribed" + }, + "message": { + "type": "string", + "const": "Webhook endpoint is not subscribed to this event type" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "EndpointTestWrongEventType" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "EndpointTestDisabled": { + "value": { + "error": { + "code": "endpoint_disabled", + "description": "Endpoint is disabled, to send test event endpoint must first be enabled.", + "message": "Webhook endpoint is disabled" + } + } + }, + "EndpointTestWrongEventType": { + "value": { + "error": { + "code": "endpoint_event_type_not_subscribed", + "description": "Only event types that the endpoint is subscribed to can be specified.", + "message": "Webhook endpoint is not subscribed to this event type" + } + } + } + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "EndpointNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_not_found" + }, + "message": { + "type": "string", + "const": "Webhook endpoint not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "endpoint_not_found", + "message": "Webhook endpoint not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Send test event", + "description": "Ingests and schedules a test webhook event to be published out this endpoints.\n\nWhich event type to use can be specified in the request body, otherwise\nit will use any matching type the endpoint is listening for.\n\nThe event will contain `is_test: true` in it's payload.\n\nThis endpoint is heavy rate-limited to allow for 10 request within 60 seconds.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "event", + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + } + }, + "required": ["type", "attributes"] + } + } + } + } + } + } + } + }, + "/v2/projects/{ref}/webhooks/deliveries/{id}": { + "get": { + "operationId": "v2-projects-ref-webhooks-deliveries-id-get", + "parameters": [ + { + "in": "path", + "name": "ref", + "schema": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst" + }, + "required": true, + "description": "Project ref" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of a delivery (UUID v7)." + } + ], + "tags": ["Project webhooks"], + "responses": { + "200": { + "description": "Delivery details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "delivery", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of a delivery (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of a delivery (UUID v7)." + }, + "event_id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of the event, which triggered the delivery (UUID v7)." + }, + "status": { + "type": "string", + "enum": ["pending", "success", "failure", "skipped"], + "description": "Status of the delivery attempt." + }, + "response_code": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "HTTP status code of the response, `0` if unavailable." + }, + "response_headers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "HTTP headers of the response, `{}` if unavailable." + }, + "response_body": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "Parsed JSON representation of an HTTP body of the response, `{}` if unavailable." + }, + { + "type": "string", + "description": "String representation of an HTTP body of the response." + } + ] + }, + { + "type": "null" + } + ] + }, + "error_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "attempt_timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of the attempt." + }, + "event": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded" + ], + "description": "Webhook event type." + }, + "payload": { + "type": "object", + "properties": { + "organization_slug": { + "type": "string", + "pattern": "^[\\w-]+$", + "description": "Organization slug", + "example": "tsrqponmlkjihgfedcba" + }, + "project_ref": { + "anyOf": [ + { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "description": "Project ref", + "example": "abcdefghijklmnopqrst" + }, + { + "type": "null" + } + ] + } + }, + "required": ["organization_slug", "project_ref"], + "description": "Final data sent to the consumer." + }, + "timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of event publication." + } + }, + "required": ["type", "payload", "timestamp"] + } + }, + "required": [ + "id", + "event_id", + "status", + "response_code", + "response_headers", + "response_body", + "error_code", + "error_message", + "attempt_timestamp", + "event" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "DeliveryNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "delivery_not_found" + }, + "message": { + "type": "string", + "const": "Webhook delivery not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "delivery_not_found", + "message": "Webhook delivery not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Get delivery", + "description": "Get details of a specific delivery attempt." + } + }, + "/v2/projects/{ref}/webhooks/deliveries/{id}/retry": { + "post": { + "operationId": "v2-projects-ref-webhooks-deliveries-id-retry-post", + "parameters": [ + { + "in": "path", + "name": "ref", + "schema": { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "example": "abcdefghijklmnopqrst" + }, + "required": true, + "description": "Project ref" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of a delivery (UUID v7)." + } + ], + "tags": ["Project webhooks"], + "responses": { + "201": { + "description": "Delivery details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "ingress", + "description": "Resource type." + }, + "id": { + "type": "string" + } + }, + "required": ["type", "id"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "DeliveryNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "delivery_not_found" + }, + "message": { + "type": "string", + "const": "Webhook delivery not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "delivery_not_found", + "message": "Webhook delivery not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Retry delivery", + "description": "Retry delivering the same event again.\n\nAutomatic retries are not applicable to manual retries - if the delivery fails, there won't be any automatic retries attempted.\n\nThis endpoint is heavy rate-limited to allow for 10 request within 60 seconds." + } + }, + "/v2/organizations/{slug}/webhooks/endpoints": { + "get": { + "operationId": "v2-organizations-slug-webhooks-endpoints-get", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string", + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba" + }, + "required": true, + "description": "Organization slug" + }, + { + "in": "query", + "name": "page[offset]", + "schema": { + "default": "0", + "type": "string", + "pattern": "^\\d+$" + }, + "description": "Offset for offset-based pagination.\n\nOffset should be dividable by `page[limit]` without a reminder (`offset % limit === 0`), otherwise it will cause weird behavior when used on a frontend." + }, + { + "in": "query", + "name": "page[limit]", + "schema": { + "default": "20", + "type": "string", + "pattern": "^\\d+$" + }, + "description": "Up to how many records to return." + } + ], + "tags": ["Organization webhooks"], + "responses": { + "200": { + "description": "Collection of endpoints", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "links": { + "type": "object", + "properties": { + "first": { + "description": "URL path to the first page if available.", + "example": "/v2/organization/slug/webhooks/endpoints?page[limit]=10&page[offset]=0", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "prev": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "URL path to the previous page.", + "example": "/v2/organization/slug/webhooks/endpoints?page[limit]=10&page[offset]=0" + }, + "next": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "URL path to the next page.", + "example": "/v2/organization/slug/webhooks/endpoints?page[limit]=10&page[offset]=20" + }, + "last": { + "description": "URL path to the last page if available.", + "example": "/v2/organization/slug/webhooks/endpoints?page[limit]=10&page[offset]=30", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["prev", "next"] + } + }, + "required": ["data", "links"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "List endpoints", + "description": "List all Webhook endpoints based on a project's ref or an organization's slug." + }, + "post": { + "operationId": "v2-organizations-slug-webhooks-endpoints-post", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string", + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba" + }, + "required": true, + "description": "Organization slug" + } + ], + "tags": ["Organization webhooks"], + "responses": { + "201": { + "description": "Created endpoint", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Create endpoint", + "description": "Create new endpoint configuration to subscribe to specific webhook events.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "default": true, + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + "signing_secret": { + "type": "string", + "minLength": 8, + "maxLength": 64, + "description": "Secret key to use when signing. All events will be signed according to the [Standard Webhooks](https://www.standardwebhooks.com/) specification." + } + }, + "required": ["url", "event_types", "signing_secret"] + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + } + } + } + } + }, + "delete": { + "operationId": "v2-organizations-slug-webhooks-endpoints-delete", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string", + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba" + }, + "required": true, + "description": "Organization slug" + } + ], + "tags": ["Organization webhooks"], + "responses": { + "200": { + "description": "Deleted endpoints", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Delete all endpoints", + "description": "Delete all endpoints including all events and deliveries.\n\nAny in-flight webhooks will result in a no-op." + } + }, + "/v2/organizations/{slug}/webhooks/endpoints/{id}": { + "get": { + "operationId": "v2-organizations-slug-webhooks-endpoints-id-get", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string", + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba" + }, + "required": true, + "description": "Organization slug" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of an endpoint (UUID v7)." + } + ], + "tags": ["Organization webhooks"], + "responses": { + "200": { + "description": "Endpoint details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "EndpointNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_not_found" + }, + "message": { + "type": "string", + "const": "Webhook endpoint not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "endpoint_not_found", + "message": "Webhook endpoint not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Get endpoint", + "description": "Get details of a specific endpoint." + }, + "patch": { + "operationId": "v2-organizations-slug-webhooks-endpoints-id-patch", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string", + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba" + }, + "required": true, + "description": "Organization slug" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of an endpoint (UUID v7)." + } + ], + "tags": ["Organization webhooks"], + "responses": { + "200": { + "description": "Updated endpoint details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "EndpointNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_not_found" + }, + "message": { + "type": "string", + "const": "Webhook endpoint not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "endpoint_not_found", + "message": "Webhook endpoint not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Update endpoint", + "description": "Update endpoint's configuration.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + "signing_secret": { + "type": "string", + "minLength": 8, + "maxLength": 64, + "description": "Secret key to use when signing. All events will be signed according to the [Standard Webhooks](https://www.standardwebhooks.com/) specification." + } + } + } + }, + "required": ["type", "attributes"] + } + }, + "required": ["data"] + } + } + } + } + }, + "delete": { + "operationId": "v2-organizations-slug-webhooks-endpoints-id-delete", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string", + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba" + }, + "required": true, + "description": "Organization slug" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of an endpoint (UUID v7)." + } + ], + "tags": ["Organization webhooks"], + "responses": { + "200": { + "description": "Deleted endpoint details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "endpoint", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of an endpoint (UUID v7)." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Publicly available URL where to send the events, must use a domain and be resolvable to a non-reserved IP address.", + "example": "https://mydomain.com/path/to/handler" + }, + "enabled": { + "type": "boolean", + "description": "Whether the endpoint is enabled or not - disabled endpoints won't emit any events." + }, + "description": { + "anyOf": [ + { + "type": "string", + "maxLength": 512 + }, + { + "type": "null" + } + ], + "description": "Optional description for the endpoint." + }, + "event_types": { + "minItems": 1, + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded", + "*" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + }, + "description": "List of subscribed events for which to receive the webhook event.", + "example": [ + { + "type": "v1.project.paused" + } + ] + }, + "custom_headers": { + "description": "Additional request headers to pass when performing HTTP request location defined by `url`. Both keys and values must be strings and a valid HTTP headers.", + "example": { + "Authorization": "Bearer example_token" + }, + "type": "object", + "propertyNames": { + "type": "string", + "pattern": "^[a-zA-Z0-9-]+$" + }, + "additionalProperties": { + "type": "string" + } + }, + "created_by": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "ID of the user who created the endpoint." + }, + "created_at": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of endpoint's creation." + } + }, + "required": [ + "id", + "url", + "enabled", + "description", + "event_types", + "created_by", + "created_at" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "EndpointNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_not_found" + }, + "message": { + "type": "string", + "const": "Webhook endpoint not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "endpoint_not_found", + "message": "Webhook endpoint not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Delete endpoint", + "description": "Delete the endpoint including all events and deliveries\n\nAny in-flight webhooks will result in a no-op." + } + }, + "/v2/organizations/{slug}/webhooks/endpoints/{id}/deliveries": { + "get": { + "operationId": "v2-organizations-slug-webhooks-endpoints-id-deliveries-get", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string", + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba" + }, + "required": true, + "description": "Organization slug" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of an endpoint (UUID v7)." + }, + { + "in": "query", + "name": "page[before]", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "description": "Cursor in cursor-based pagination to return up to `page[size]` records before (exclusive) the entry specified by this query param." + }, + { + "in": "query", + "name": "page[after]", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "description": "Cursor in cursor-based pagination to return up to `page[size]` records after (exclusive) the entry specified by this query param." + }, + { + "in": "query", + "name": "page[size]", + "schema": { + "default": "20", + "type": "string", + "pattern": "^\\d+$" + }, + "description": "Up to how many records to return." + } + ], + "tags": ["Organization webhooks"], + "responses": { + "200": { + "description": "List of deliveries", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "delivery", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of a delivery (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of a delivery (UUID v7)." + }, + "event_id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of the event, which triggered the delivery (UUID v7)." + }, + "status": { + "type": "string", + "enum": ["pending", "success", "failure", "skipped"], + "description": "Status of the delivery attempt." + }, + "response_code": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "HTTP status code of the response, `0` if unavailable." + }, + "response_headers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "HTTP headers of the response, `{}` if unavailable." + }, + "response_body": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "Parsed JSON representation of an HTTP body of the response, `{}` if unavailable." + }, + { + "type": "string", + "description": "String representation of an HTTP body of the response." + } + ] + }, + { + "type": "null" + } + ] + }, + "error_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "attempt_timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of the attempt." + }, + "event": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded" + ], + "description": "Webhook event type." + }, + "payload": { + "type": "object", + "properties": { + "organization_slug": { + "type": "string", + "pattern": "^[\\w-]+$", + "description": "Organization slug", + "example": "tsrqponmlkjihgfedcba" + }, + "project_ref": { + "anyOf": [ + { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "description": "Project ref", + "example": "abcdefghijklmnopqrst" + }, + { + "type": "null" + } + ] + } + }, + "required": ["organization_slug", "project_ref"], + "description": "Final data sent to the consumer." + }, + "timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of event publication." + } + }, + "required": ["type", "payload", "timestamp"] + } + }, + "required": [ + "id", + "event_id", + "status", + "response_code", + "response_headers", + "response_body", + "error_code", + "error_message", + "attempt_timestamp", + "event" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "links": { + "type": "object", + "properties": { + "first": { + "description": "URL path to the first page if available.", + "example": "/v2/organizations/slug/webhooks/endpoints/{id}/deliveries?page[size]=10", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "prev": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "URL path to the previous page.", + "example": "/v2/organizations/slug/webhooks/endpoints/{id}/deliveries?page[size]=10&page[before]=019adf7d-4513-74c5-bb9a-f1bc0f7a95d7" + }, + "next": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "URL path to the next page.", + "example": "/v2/organizations/slug/webhooks/endpoints/{id}/deliveries?page[size]=10&page[after]=019adf7d-4513-7062-b292-78b86cc470a4" + }, + "last": { + "description": "URL path to the last page if available.", + "example": "/v2/organizations/slug/webhooks/endpoints/{id}/deliveries?page[size]=10&page[after]=019adf7d-4513-71ba-b264-21900edb4295", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": ["prev", "next"] + } + }, + "required": ["data", "links"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "EndpointNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_not_found" + }, + "message": { + "type": "string", + "const": "Webhook endpoint not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "endpoint_not_found", + "message": "Webhook endpoint not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "List deliveries", + "description": "List all deliveries for a specific endpoint in descending order (newest first).\n\nDeliveries which has expired are no longer available and will not be listed." + } + }, + "/v2/organizations/{slug}/webhooks/endpoints/{id}/test": { + "post": { + "operationId": "v2-organizations-slug-webhooks-endpoints-id-test-post", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string", + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba" + }, + "required": true, + "description": "Organization slug" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of an endpoint (UUID v7)." + } + ], + "tags": ["Organization webhooks"], + "responses": { + "201": { + "description": "Event published", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "ingress", + "description": "Resource type." + }, + "id": { + "type": "string" + } + }, + "required": ["type", "id"] + } + }, + "required": ["data"] + } + } + } + }, + "400": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_disabled" + }, + "message": { + "type": "string", + "const": "Webhook endpoint is disabled" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "EndpointTestDisabled" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_event_type_not_subscribed" + }, + "message": { + "type": "string", + "const": "Webhook endpoint is not subscribed to this event type" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "EndpointTestWrongEventType" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "EndpointTestDisabled": { + "value": { + "error": { + "code": "endpoint_disabled", + "description": "Endpoint is disabled, to send test event endpoint must first be enabled.", + "message": "Webhook endpoint is disabled" + } + } + }, + "EndpointTestWrongEventType": { + "value": { + "error": { + "code": "endpoint_event_type_not_subscribed", + "description": "Only event types that the endpoint is subscribed to can be specified.", + "message": "Webhook endpoint is not subscribed to this event type" + } + } + } + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "EndpointNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "endpoint_not_found" + }, + "message": { + "type": "string", + "const": "Webhook endpoint not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "endpoint_not_found", + "message": "Webhook endpoint not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Send test event", + "description": "Ingests and schedules a test webhook event to be published out this endpoints.\n\nWhich event type to use can be specified in the request body, otherwise\nit will use any matching type the endpoint is listening for.\n\nThe event will contain `is_test: true` in it's payload.\n\nThis endpoint is heavy rate-limited to allow for 10 request within 60 seconds.", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "event", + "description": "Resource type." + }, + "attributes": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded" + ], + "description": "Webhook event type." + } + }, + "required": ["type"] + } + }, + "required": ["type", "attributes"] + } + } + } + } + } + } + } + }, + "/v2/organizations/{slug}/webhooks/deliveries/{id}": { + "get": { + "operationId": "v2-organizations-slug-webhooks-deliveries-id-get", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string", + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba" + }, + "required": true, + "description": "Organization slug" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of a delivery (UUID v7)." + } + ], + "tags": ["Organization webhooks"], + "responses": { + "200": { + "description": "Delivery details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "delivery", + "description": "Resource type." + }, + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of a delivery (UUID v7)." + }, + "attributes": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of a delivery (UUID v7)." + }, + "event_id": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "description": "Identifier of the event, which triggered the delivery (UUID v7)." + }, + "status": { + "type": "string", + "enum": ["pending", "success", "failure", "skipped"], + "description": "Status of the delivery attempt." + }, + "response_code": { + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991, + "description": "HTTP status code of the response, `0` if unavailable." + }, + "response_headers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "HTTP headers of the response, `{}` if unavailable." + }, + "response_body": { + "anyOf": [ + { + "anyOf": [ + { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "Parsed JSON representation of an HTTP body of the response, `{}` if unavailable." + }, + { + "type": "string", + "description": "String representation of an HTTP body of the response." + } + ] + }, + { + "type": "null" + } + ] + }, + "error_code": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "attempt_timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of the attempt." + }, + "event": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "v1.project.paused", + "v1.project.created", + "v1.project.restored", + "v1.project.transferred", + "v1.project.removed", + "v1.project.restarted", + "v1.project.status.changed", + "v1.project.backup.started", + "v1.project.branch.created", + "v1.project.branch.updated", + "v1.project.branch.removed", + "v1.organization.member.invitation.created", + "v1.organization.member.invitation.canceled", + "v1.organization.member.added", + "v1.organization.member.removed", + "v1.organization.member.role.assigned", + "v1.organization.member.role.removed", + "v1.organization.member.role.updated", + "v1.organization.billing.plan.upgraded", + "v1.organization.billing.plan.downgraded" + ], + "description": "Webhook event type." + }, + "payload": { + "type": "object", + "properties": { + "organization_slug": { + "type": "string", + "pattern": "^[\\w-]+$", + "description": "Organization slug", + "example": "tsrqponmlkjihgfedcba" + }, + "project_ref": { + "anyOf": [ + { + "type": "string", + "minLength": 20, + "maxLength": 20, + "pattern": "^[a-z]+$", + "description": "Project ref", + "example": "abcdefghijklmnopqrst" + }, + { + "type": "null" + } + ] + } + }, + "required": ["organization_slug", "project_ref"], + "description": "Final data sent to the consumer." + }, + "timestamp": { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$", + "description": "Timestamp of event publication." + } + }, + "required": ["type", "payload", "timestamp"] + } + }, + "required": [ + "id", + "event_id", + "status", + "response_code", + "response_headers", + "response_body", + "error_code", + "error_message", + "attempt_timestamp", + "event" + ] + } + }, + "required": ["type", "id", "attributes"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "DeliveryNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "delivery_not_found" + }, + "message": { + "type": "string", + "const": "Webhook delivery not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "delivery_not_found", + "message": "Webhook delivery not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Get delivery", + "description": "Get details of a specific delivery attempt." + } + }, + "/v2/organizations/{slug}/webhooks/deliveries/{id}/retry": { + "post": { + "operationId": "v2-organizations-slug-webhooks-deliveries-id-retry-post", + "parameters": [ + { + "in": "path", + "name": "slug", + "schema": { + "type": "string", + "pattern": "^[\\w-]+$", + "example": "tsrqponmlkjihgfedcba" + }, + "required": true, + "description": "Organization slug" + }, + { + "in": "path", + "name": "id", + "schema": { + "type": "string", + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$" + }, + "required": true, + "description": "Identifier of a delivery (UUID v7)." + } + ], + "tags": ["Organization webhooks"], + "responses": { + "201": { + "description": "Delivery details", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "ingress", + "description": "Resource type." + }, + "id": { + "type": "string" + } + }, + "required": ["type", "id"] + } + }, + "required": ["data"] + } + } + } + }, + "401": { + "description": "GenericUnauthorized", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "unauthorized" + }, + "message": { + "type": "string", + "const": "Unauthorized" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "unauthorized", + "message": "Unauthorized" + } + } + } + } + } + } + }, + "403": { + "description": "Multiple error responses", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "oneOf": [ + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "forbidden" + }, + "message": { + "type": "string", + "const": "Forbidden" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "PermissionDenied" + }, + { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "webhooks_access_disabled" + }, + "message": { + "type": "string", + "const": "Webhooks access is disabled for this organization or project" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"], + "title": "AccessDisabled" + } + ] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "PermissionDenied": { + "value": { + "error": { + "code": "forbidden", + "message": "Forbidden" + } + } + }, + "AccessDisabled": { + "value": { + "error": { + "code": "webhooks_access_disabled", + "message": "Webhooks access is disabled for this organization or project" + } + } + } + } + } + } + }, + "404": { + "description": "DeliveryNotFound", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "delivery_not_found" + }, + "message": { + "type": "string", + "const": "Webhook delivery not found" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "delivery_not_found", + "message": "Webhook delivery not found" + } + } + } + } + } + } + }, + "408": { + "description": "GenericRequestTimeout", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "request_timeout" + }, + "message": { + "type": "string", + "const": "Request timed out" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "request_timeout", + "message": "Request timed out" + } + } + } + } + } + } + }, + "429": { + "description": "GenericTooManyRequests", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "rate_limit_exceeded" + }, + "message": { + "type": "string", + "const": "Rate limit exceeded" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "rate_limit_exceeded", + "message": "Rate limit exceeded" + } + } + } + } + } + } + }, + "500": { + "description": "GenericInternalServerError", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "const": "internal_server_error" + }, + "message": { + "type": "string", + "const": "Internal Server Error" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "required": ["code", "message"] + } + }, + "required": ["error"], + "$defs": { + "APIErrorObject": { + "$ref": "#/components/schemas/APIErrorObject" + } + } + }, + "examples": { + "Default example": { + "value": { + "error": { + "code": "internal_server_error", + "message": "Internal Server Error" + } + } + } + } + } + } + } + }, + "summary": "Retry delivery", + "description": "Retry delivering the same event again.\n\nAutomatic retries are not applicable to manual retries - if the delivery fails, there won't be any automatic retries attempted.\n\nThis endpoint is heavy rate-limited to allow for 10 request within 60 seconds." + } } }, "components": { @@ -15520,7 +32436,8 @@ "type": "string" } }, - "required": ["id", "username"] + "required": ["id", "username"], + "nullable": true }, "updated_by": { "type": "object", @@ -15532,7 +32449,8 @@ "type": "string" } }, - "required": ["id", "username"] + "required": ["id", "username"], + "nullable": true }, "favorite": { "type": "boolean" @@ -15608,7 +32526,8 @@ "type": "string" } }, - "required": ["id", "username"] + "required": ["id", "username"], + "nullable": true }, "updated_by": { "type": "object", @@ -15620,7 +32539,8 @@ "type": "string" } }, - "required": ["id", "username"] + "required": ["id", "username"], + "nullable": true }, "favorite": { "type": "boolean" @@ -15640,7 +32560,8 @@ "type": "string" } }, - "required": ["sql"] + "required": ["sql"], + "nullable": true } }, "required": [ @@ -18857,6 +35778,7 @@ "mfa_phone_template", "mfa_phone_max_frequency", "nimbus_oauth_client_id", + "nimbus_oauth_email_optional", "nimbus_oauth_client_secret", "password_hibp_enabled", "password_min_length", @@ -21530,8 +38452,7 @@ "type": "string" }, "import_map_path": { - "type": "string", - "nullable": true + "type": "string" }, "ezbr_sha256": { "type": "string" @@ -21672,8 +38593,7 @@ "type": "string" }, "import_map_path": { - "type": "string", - "nullable": true + "type": "string" }, "ezbr_sha256": { "type": "string" @@ -21773,8 +38693,7 @@ "type": "string" }, "import_map_path": { - "type": "string", - "nullable": true + "type": "string" }, "ezbr_sha256": { "type": "string" @@ -21825,8 +38744,7 @@ "type": "string" }, "import_map_path": { - "type": "string", - "nullable": true + "type": "string" }, "ezbr_sha256": { "type": "string" @@ -28778,6 +45696,169 @@ } }, "required": ["data", "links"] + }, + "APIErrorObject": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string", + "enum": [ + "bad_request", + "forbidden", + "internal_server_error", + "not_found", + "payment_required", + "rate_limit_exceeded", + "request_timeout", + "request_too_large", + "unauthorized", + "validation_failed", + "agentic_credential_not_found", + "branch_admin_required", + "compute_build_context_too_large", + "compute_build_in_progress", + "compute_deploy_rate_limit_exceeded", + "compute_instance_limit_exceeded", + "compute_instance_not_found", + "compute_not_enabled", + "compute_request_rejected", + "compute_unavailable", + "notebook_cell_duplicate", + "notebook_cell_unknown", + "notebook_disabled", + "notebook_not_found", + "notebook_sort_invalid", + "organization_invitation_email_delivery_failed", + "organization_invitations_partially_failed", + "organization_role_name_invalid", + "organization_role_not_entitled", + "pagination_cursor_invalid", + "project_branch_resync_unsupported", + "project_not_found", + "project_privatelink_disabled", + "project_transfer_disabled", + "delivery_not_found", + "endpoint_disabled", + "endpoint_event_type_not_subscribed", + "endpoint_not_found", + "endpoint_url_unresolvable", + "webhooks_access_disabled" + ] + }, + "message": { + "type": "string" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "issues": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "code": { + "type": "string" + }, + "message": { + "type": "string" + }, + "description": { + "type": "string" + }, + "links": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "object", + "properties": { + "href": { + "type": "string" + }, + "rel": { + "type": "string" + }, + "title": { + "type": "string" + }, + "type": { + "type": "string" + }, + "describedby": { + "type": "string" + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["href"] + } + }, + "meta": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": ["code", "message"] + } + } + }, + "required": ["code", "message"] } } } diff --git a/packages/api/src/internal/client.ts b/packages/api/src/internal/client.ts index bbd8668d50..7a4d3a0855 100644 --- a/packages/api/src/internal/client.ts +++ b/packages/api/src/internal/client.ts @@ -1,4 +1,4 @@ -import { Effect, Option, Context } from "effect"; +import { Clock, Data, Duration, Effect, Option, Context, Random, Schedule } from "effect"; import * as Cause from "effect/Cause"; import * as Redacted from "effect/Redacted"; import type { SchemaError } from "effect/Schema"; @@ -32,11 +32,34 @@ interface ResolvedSupabaseApiConfig { readonly headers?: Readonly> | undefined; } +/** Per-request overrides of the construction-time base URL, bearer token, headers, and retry policy. */ +export interface SupabaseApiRequestOptions { + readonly baseUrl?: string | undefined; + readonly accessToken?: string | Redacted.Redacted | undefined; + readonly headers?: Readonly> | undefined; + readonly retry?: SupabaseApiRetryOptions | undefined; +} + +/** + * Retry policy for Management API requests. Failed attempts back off exponentially with full + * jitter and honour `Retry-After` on any retried response. + */ export interface SupabaseApiRetryOptions { + /** Retries after the first attempt; defaults to 5. */ readonly maxRetries?: number | undefined; + /** Timeout of each attempt in milliseconds; defaults to 60 seconds, `Infinity` disables it. */ readonly requestTimeoutMs?: number | undefined; } +/** + * Request options for `executeRaw`. `extraBody` fields are forwarded unvalidated, for + * read-modify-write of server state the spec does not fully describe. + */ +export interface SupabaseApiRawRequestOptions extends SupabaseApiRequestOptions { + /** Extra JSON body fields; fields the operation's schema declares take precedence. */ + readonly extraBody?: Readonly> | undefined; +} + export interface SupabaseApiClientOptions { readonly retry?: SupabaseApiRetryOptions | undefined; readonly transformClient?: @@ -54,6 +77,7 @@ export interface SupabaseApiClientShape { readonly execute: ( definition: OperationDefinition, input: OperationInput, + options?: SupabaseApiRequestOptions, ) => Effect.Effect, SupabaseApiError>; /** * Executes an operation but returns the raw HTTP response, without decoding @@ -65,7 +89,7 @@ export interface SupabaseApiClientShape { readonly executeRaw: ( definition: OperationDefinition, input: OperationInput, - headers?: Readonly>, + options?: SupabaseApiRawRequestOptions, ) => Effect.Effect; } @@ -184,6 +208,13 @@ function revealRedactedValue(value: unknown): unknown { return value; } +const DEFAULT_MAX_RETRIES = 5; +const DEFAULT_REQUEST_TIMEOUT_MS = 60_000; +const RETRY_BASE_DELAY = Duration.millis(250); +const RETRY_MAX_DELAY = Duration.seconds(4); +/** Longest `Retry-After` honoured; a longer wait returns the response to the caller instead of retrying. */ +const RETRY_AFTER_MAX_DELAY = Duration.seconds(10); + function isIdempotentMethod(method: string): boolean { return method === "GET" || method === "PUT" || method === "DELETE" || method === "HEAD"; } @@ -192,9 +223,76 @@ function isRetryableTransportError(error: unknown): error is HttpClientError.Htt return HttpClientError.isHttpClientError(error) && error.reason._tag === "TransportError"; } +/** A 429 means the server did not process the request, so every method may be resent. */ function isRetryableResponse(response: HttpClientResponse.HttpClientResponse): boolean { return ( - response.status >= 500 && response.status <= 599 && isIdempotentMethod(response.request.method) + response.status === 429 || + (response.status >= 500 && + response.status <= 599 && + isIdempotentMethod(response.request.method)) + ); +} + +/** A retryable response travelling the error channel so the retry schedule can inspect it. */ +class RetryableResponse extends Data.TaggedError("RetryableResponse")<{ + readonly response: HttpClientResponse.HttpClientResponse; +}> {} + +type RetryableFailure = HttpClientError.HttpClientError | RetryableResponse; + +/** Parses a `Retry-After` value, either delta-seconds or an HTTP-date, into a wait. */ +function parseRetryAfter( + value: string | undefined, + nowMillis: number, +): Option.Option { + const trimmed = value?.trim(); + if (trimmed === undefined || trimmed === "") { + return Option.none(); + } + if (/^\d+$/.test(trimmed)) { + return Option.some(Duration.seconds(Number(trimmed))); + } + // HTTP-dates start with a day name; this keeps `Date.parse` from reading bare numbers as years. + const dateMillis = /^[A-Za-z]/.test(trimmed) ? Date.parse(trimmed) : Number.NaN; + return Number.isNaN(dateMillis) + ? Option.none() + : Option.some(Duration.millis(Math.max(0, dateMillis - nowMillis))); +} + +function retryAfterOf( + failure: RetryableFailure, + nowMillis: number, +): Option.Option { + if (failure._tag !== "RetryableResponse") { + return Option.none(); + } + return parseRetryAfter(failure.response.headers["retry-after"], nowMillis); +} + +function exceedsRetryAfterCap( + response: HttpClientResponse.HttpClientResponse, + nowMillis: number, +): boolean { + return Option.exists( + parseRetryAfter(response.headers["retry-after"], nowMillis), + Duration.isGreaterThan(RETRY_AFTER_MAX_DELAY), + ); +} + +function makeRetrySchedule(maxRetries: number) { + return Schedule.exponential(RETRY_BASE_DELAY).pipe( + Schedule.setInputType(), + Schedule.modifyDelay(({ input, duration, now }) => + Option.match(retryAfterOf(input, now), { + onSome: Effect.succeed, + onNone: () => + Effect.map(Random.next, (random) => + Duration.times(Duration.min(duration, RETRY_MAX_DELAY), random), + ), + }), + ), + Schedule.upTo({ times: maxRetries }), + Schedule.tap(({ attempt }) => Effect.annotateCurrentSpan("http.request.resend_count", attempt)), ); } @@ -202,16 +300,21 @@ function applySupabaseRetryPolicy( client: HttpClient.HttpClient, options?: SupabaseApiRetryOptions, ): HttpClient.HttpClient { - const maxRetries = options?.maxRetries ?? 5; - const timeoutMs = options?.requestTimeoutMs ?? 60_000; - - return HttpClient.transform(client, (requestEffect, request) => { - const retriesTransportErrors = isIdempotentMethod(request.method); - const attempt = ( - retries: number, - ): Effect.Effect => - requestEffect.pipe( - Effect.timeout(timeoutMs), + return HttpClient.transform(client, (requestEffect, request) => + Effect.flatMap(Effect.service(RequestOverrides), (overrides) => { + const timeoutMs = + overrides?.retry?.requestTimeoutMs ?? + options?.requestTimeoutMs ?? + DEFAULT_REQUEST_TIMEOUT_MS; + const maxRetries = overrides?.retry?.maxRetries ?? options?.maxRetries ?? DEFAULT_MAX_RETRIES; + const retriesTransportErrors = isIdempotentMethod(request.method); + const attempt: Effect.Effect< + HttpClientResponse.HttpClientResponse, + HttpClientError.HttpClientError | Cause.TimeoutError, + never + > = Number.isFinite(timeoutMs) ? Effect.timeout(requestEffect, timeoutMs) : requestEffect; + + return attempt.pipe( Effect.catchIf(Cause.isTimeoutError, (error) => Effect.fail( new HttpClientError.HttpClientError({ @@ -223,20 +326,60 @@ function applySupabaseRetryPolicy( }), ), ), - Effect.catchIf(isRetryableTransportError, (error) => - retries < maxRetries && retriesTransportErrors - ? attempt(retries + 1) - : Effect.fail(error), - ), Effect.flatMap((response) => - isRetryableResponse(response) && retries < maxRetries - ? attempt(retries + 1) - : Effect.succeed(response), + Effect.flatMap(Clock.currentTimeMillis, (nowMillis) => + isRetryableResponse(response) && !exceedsRetryAfterCap(response, nowMillis) + ? Effect.fail(new RetryableResponse({ response })) + : Effect.succeed(response), + ), ), + Effect.retry({ + schedule: makeRetrySchedule(maxRetries), + while: (failure) => + failure._tag === "RetryableResponse" || + (retriesTransportErrors && isRetryableTransportError(failure)), + }), + Effect.catchTag("RetryableResponse", (failure) => Effect.succeed(failure.response)), ); + }), + ); +} - return attempt(0); - }); +/** + * Carries one request's overrides to the innermost client layer, so the client + * handed to `transformClient` stays fully configured and overridden requests + * still pass through it, the retry policy, and the timeout. + */ +const RequestOverrides = Context.Reference( + "@supabase/api/RequestOverrides", + { defaultValue: () => undefined }, +); + +function applyRequestDefaults( + request: HttpClientRequest.HttpClientRequest, + config: ResolvedSupabaseApiConfig, + overrides: SupabaseApiRequestOptions | undefined, +): HttpClientRequest.HttpClientRequest { + const accessToken = overrides?.accessToken ?? config.accessToken; + let next = HttpClientRequest.prependUrl(request, overrides?.baseUrl ?? config.baseUrl); + next = HttpClientRequest.setHeader( + next, + "Authorization", + `Bearer ${String(revealRedactedValue(accessToken))}`, + ); + next = HttpClientRequest.setHeader( + next, + "User-Agent", + config.userAgent ?? "supabase-api/unknown", + ); + for (const headers of [config.headers, overrides?.headers]) { + for (const [name, value] of Object.entries(headers ?? {})) { + if (value !== undefined) { + next = HttpClientRequest.setHeader(next, name, value); + } + } + } + return next; } function prepareClient( @@ -245,27 +388,11 @@ function prepareClient( options?: SupabaseApiClientOptions, ): Effect.Effect { const prefixed = client.pipe( - HttpClient.mapRequest((request) => { - let next = HttpClientRequest.prependUrl(request, config.baseUrl); - next = HttpClientRequest.setHeader( - next, - "Authorization", - `Bearer ${String(revealRedactedValue(config.accessToken))}`, - ); - next = HttpClientRequest.setHeader( - next, - "User-Agent", - config.userAgent ?? "supabase-api/unknown", - ); - if (config.headers !== undefined) { - for (const [name, value] of Object.entries(config.headers)) { - if (value !== undefined) { - next = HttpClientRequest.setHeader(next, name, value); - } - } - } - return next; - }), + HttpClient.mapRequestEffect((request) => + Effect.map(Effect.service(RequestOverrides), (overrides) => + applyRequestDefaults(request, config, overrides), + ), + ), // Trace context stays local; no API consumer reads it. HttpClient.transformResponse(Effect.provideService(HttpClient.TracerPropagationEnabled, false)), ); @@ -406,13 +533,18 @@ function encodeBody( request: HttpClientRequest.HttpClientRequest, definition: OperationDefinition, input: object, + extraBody: Readonly> | undefined, ): Effect.Effect { + if (extraBody !== undefined && definition.requestBody.kind !== "json") { + return Effect.die(`${definition.id} has no JSON field body to extend with extraBody.`); + } + if (definition.requestBody.kind === "none") { return Effect.succeed(request); } if (definition.requestBody.kind === "json") { - const payload: Record = {}; + const payload: Record = { ...extraBody }; for (const field of definition.requestBody.fields) { const value = Reflect.get(input, field); if (value !== undefined) { @@ -451,6 +583,7 @@ function encodeBody( function buildRequest( definition: OperationDefinition, input: object, + extraBody?: Readonly>, ): Effect.Effect { const path = interpolatePath(definition.path, definition.pathParams, input); let request = HttpClientRequest.make(definition.method)(path); @@ -490,17 +623,20 @@ function buildRequest( } } - return encodeBody(request, definition, input); + return encodeBody(request, definition, input, extraBody); } function executeRequest( client: HttpClient.HttpClient, definition: OperationDefinition, input: object, + options: SupabaseApiRequestOptions | undefined, ): Effect.Effect { return Effect.gen(function* () { const request = yield* buildRequest(definition, input); - const response = yield* client.execute(request); + const response = yield* client + .execute(request) + .pipe(Effect.provideService(RequestOverrides, options)); return yield* HttpClientResponse.filterStatusOk(response); }).pipe(Effect.withSpan(definition.id, { attributes: { "api.operation": definition.id } })); } @@ -569,14 +705,20 @@ export function makeSupabaseApiClient( ); const httpClient = yield* HttpClient.HttpClient; const prepared = yield* prepareClient(httpClient, resolvedConfig, options); + // Excess keys fail instead of being stripped so a caller typo or stale field + // never silently drops out of the request. + const decodeInput = (definition: OperationDefinition, input: unknown) => + Schema.decodeUnknownEffect(definition.inputSchema)(input, { + onExcessProperty: "error", + }).pipe( + Effect.mapError((error) => new SupabaseApiInputError(error.message, { cause: error })), + ); return { - execute: (definition, input) => + execute: (definition, input, requestOptions) => Effect.gen(function* () { - const validated = yield* Schema.decodeUnknownEffect(definition.inputSchema)(input).pipe( - Effect.mapError((error) => new SupabaseApiInputError(error.message, { cause: error })), - ); - const response = yield* executeRequest(prepared, definition, validated); + const validated = yield* decodeInput(definition, input); + const response = yield* executeRequest(prepared, definition, validated, requestOptions); if (isJsonOperation(definition)) { return yield* decodeJsonResponse(definition, response); } @@ -588,17 +730,13 @@ export function makeSupabaseApiClient( } return yield* Effect.die(`Unsupported response kind: ${definition.response.kind}`); }), - executeRaw: (definition, input, headers) => + executeRaw: (definition, input, requestOptions) => Effect.gen(function* () { - const validated = yield* Schema.decodeUnknownEffect(definition.inputSchema)(input).pipe( - Effect.mapError((error) => new SupabaseApiInputError(error.message, { cause: error })), - ); - const request = yield* buildRequest(definition, validated).pipe( - Effect.map((request) => - headers === undefined ? request : HttpClientRequest.setHeaders(request, headers), - ), - ); - return yield* prepared.execute(request); + const validated = yield* decodeInput(definition, input); + const request = yield* buildRequest(definition, validated, requestOptions?.extraBody); + return yield* prepared + .execute(request) + .pipe(Effect.provideService(RequestOverrides, requestOptions)); }).pipe(Effect.withSpan(definition.id, { attributes: { "api.operation": definition.id } })), }; }); diff --git a/packages/api/src/internal/client.unit.test.ts b/packages/api/src/internal/client.unit.test.ts index 1943eac670..dbd83aa270 100644 --- a/packages/api/src/internal/client.unit.test.ts +++ b/packages/api/src/internal/client.unit.test.ts @@ -1,10 +1,22 @@ import { describe, expect, test } from "vitest"; -import { Effect, Exit, Layer, Option, Redacted } from "effect"; +import { + Cause, + Clock, + Duration, + Effect, + Exit, + Fiber, + Layer, + Option, + Random, + Redacted, +} from "effect"; +import { TestClock } from "effect/testing"; import * as HttpBody from "effect/http/HttpBody"; import * as HttpClient from "effect/http/HttpClient"; import * as HttpClientError from "effect/http/HttpClientError"; import * as HttpClientResponse from "effect/http/HttpClientResponse"; -import type * as HttpClientRequest from "effect/http/HttpClientRequest"; +import * as HttpClientRequest from "effect/http/HttpClientRequest"; import * as UrlParams from "effect/http/UrlParams"; import * as Schema from "effect/Schema"; @@ -12,6 +24,9 @@ import { operationDefinitions } from "../generated/contracts.ts"; import { makeSupabaseApiClient, markSupabaseApiInputErrorAsUserInput, + type SupabaseApiClientOptions, + type SupabaseApiClientShape, + type SupabaseApiRetryOptions, SupabaseApiInputError, } from "./client.ts"; @@ -159,6 +174,60 @@ const config = { userAgent: "supabase-api/test", } as const; +/** Runs `effect` on a virtual clock with every retry jitter draw fixed at `jitter`. */ +function onVirtualClock( + effect: Effect.Effect, + jitter = 0.5, +): Promise { + return Effect.runPromise( + effect.pipe( + Effect.provideService(Random.Random, { + nextIntUnsafe: () => 0, + nextDoubleUnsafe: () => jitter, + }), + Effect.provide(TestClock.layer()), + ), + ); +} + +/** + * Starts one client call against a stub transport on the virtual clock and records the clock + * reading of every request the transport receives. + */ +function startVirtualCall( + call: (client: SupabaseApiClientShape) => Effect.Effect, + respond: ( + request: HttpClientRequest.HttpClientRequest, + attempt: number, + ) => HttpClientResponse.HttpClientResponse | HttpClientError.HttpClientError, + options?: SupabaseApiClientOptions, +) { + return Effect.gen(function* () { + const requestTimes: Array = []; + const fiber = yield* makeSupabaseApiClient(config, options).pipe( + Effect.flatMap(call), + Effect.exit, + Effect.provide( + httpClientLayer((request) => + Effect.gen(function* () { + requestTimes.push(yield* Clock.currentTimeMillis); + const outcome = respond(request, requestTimes.length); + return yield* HttpClientError.isHttpClientError(outcome) + ? Effect.fail(outcome) + : Effect.succeed(outcome); + }), + ), + ), + Effect.forkChild, + ); + yield* Effect.yieldNow; + return { requestTimes, fiber }; + }); +} + +const listProjects = (client: SupabaseApiClientShape) => + client.execute<"v1ListAllProjects">(operationDefinitions.v1ListAllProjects, {}); + describe("makeSupabaseApiClient", () => { test("defaults request-schema failures to generated-client provenance", async () => { let requests = 0; @@ -206,6 +275,142 @@ describe("makeSupabaseApiClient", () => { expect(requests).toBe(0); }); + test("rejects unknown request keys at any depth before sending a request", async () => { + let requests = 0; + const client = await Effect.runPromise( + makeSupabaseApiClient(config).pipe( + Effect.provide( + httpClientLayer((request) => { + requests += 1; + return Effect.succeed(jsonResponse(request, 200, {})); + }), + ), + ), + ); + const ref = "abcdefghijklmnopqrst"; + const topLevelExcess = { ref, requestedConfig: { database: true }, junk: 1 }; + const nestedExcess = { ref, requestedConfig: { database: true, junk: 1 } }; + + const errors = await Promise.all([ + Effect.runPromise( + client + .execute(operationDefinitions.v1UpdateSslEnforcementConfig, topLevelExcess) + .pipe(Effect.flip), + ), + Effect.runPromise( + client + .executeRaw(operationDefinitions.v1UpdateSslEnforcementConfig, nestedExcess) + .pipe(Effect.flip), + ), + ]); + + for (const error of errors) { + expect(error).toBeInstanceOf(SupabaseApiInputError); + } + expect(errors[0]?.message).toContain('["junk"]'); + expect(errors[1]?.message).toContain('["requestedConfig"]["junk"]'); + expect(requests).toBe(0); + }); + + test("per-request options override the base URL, token, and headers for that request only", async () => { + const seen: Array<{ + url: string; + authorization: string | undefined; + userAgent: string | undefined; + custom: string | undefined; + }> = []; + const client = await Effect.runPromise( + makeSupabaseApiClient({ ...config, headers: { "X-Custom": "default" } }).pipe( + Effect.provide( + httpClientLayer((request) => { + seen.push({ + url: request.url, + authorization: request.headers.authorization, + userAgent: request.headers["user-agent"], + custom: request.headers["x-custom"], + }); + return Effect.succeed(jsonResponse(request, 200, [])); + }), + ), + ), + ); + + await Effect.runPromise( + client.execute( + operationDefinitions.v1ListAllProjects, + {}, + { + baseUrl: "https://api.example.test/prefix", + accessToken: Redacted.make("override-token"), + headers: { "X-Custom": "override" }, + }, + ), + ); + await Effect.runPromise( + client.executeRaw( + operationDefinitions.v1ListAllProjects, + {}, + { baseUrl: "https://raw.example.test", accessToken: "raw-token" }, + ), + ); + await Effect.runPromise(client.execute(operationDefinitions.v1ListAllProjects, {})); + + expect(seen).toEqual([ + { + url: "https://api.example.test/prefix/v1/projects", + authorization: "Bearer override-token", + userAgent: "supabase-api/test", + custom: "override", + }, + { + url: "https://raw.example.test/v1/projects", + authorization: "Bearer raw-token", + userAgent: "supabase-api/test", + custom: "default", + }, + { + url: "https://api.supabase.com/v1/projects", + authorization: "Bearer test-token", + userAgent: "supabase-api/test", + custom: "default", + }, + ]); + }); + + test("requests with per-request overrides still pass through transformClient", async () => { + const transformed: Array = []; + const wire: Array<{ url: string; stamp: string | undefined }> = []; + const client = await Effect.runPromise( + makeSupabaseApiClient(config, { + transformClient: (inner) => + Effect.succeed( + HttpClient.mapRequest(inner, (request) => { + transformed.push(request.url); + return HttpClientRequest.setHeader(request, "X-Stamp", "stamped"); + }), + ), + }).pipe( + Effect.provide( + httpClientLayer((request) => { + wire.push({ url: request.url, stamp: request.headers["x-stamp"] }); + return Effect.succeed(jsonResponse(request, 200, [])); + }), + ), + ), + ); + + await Effect.runPromise( + client.execute( + operationDefinitions.v1ListAllProjects, + {}, + { baseUrl: "https://api.example.test" }, + ), + ); + + expect(transformed).toEqual(["https://api.example.test/v1/projects"]); + expect(wire).toEqual([{ url: "https://api.example.test/v1/projects", stamp: "stamped" }]); + }); + test("fails request-body construction before sending a request", async () => { class BrokenBlob extends Blob { override arrayBuffer(): Promise { @@ -237,29 +442,67 @@ describe("makeSupabaseApiClient", () => { expect(requests).toBe(0); }); - test("retries transport errors for GET requests", async () => { - let attempts = 0; - - const result = await Effect.runPromise( + test("executeRaw forwards extraBody fields and lets validated fields win", async () => { + const bodies: unknown[] = []; + await Effect.runPromise( makeSupabaseApiClient(config).pipe( Effect.flatMap((client) => - client.execute<"v1ListAllProjects">(operationDefinitions.v1ListAllProjects, {}), + client.executeRaw( + operationDefinitions.v1UpdatePostgresConfig, + { ref: "abcdefghijklmnopqrst", effective_cache_size: "4GB" }, + { + extraBody: { + effective_cache_size: "1GB", + future_parameter: "on", + }, + }, + ), ), Effect.provide( httpClientLayer((request) => { - attempts += 1; - if (attempts < 3) { - return Effect.fail(transportError(request, "socket reset")); - } + bodies.push(JSON.parse(requestBodyText(request))); + return Effect.succeed(jsonResponse(request, 200, {})); + }), + ), + ), + ); + + expect(bodies).toEqual([{ effective_cache_size: "4GB", future_parameter: "on" }]); + }); + test("executeRaw rejects extraBody on operations without a JSON field body", async () => { + let requests = 0; + const exit = await Effect.runPromiseExit( + makeSupabaseApiClient(config).pipe( + Effect.flatMap((client) => + client.executeRaw(operationDefinitions.v1ListAllProjects, {}, { extraBody: { a: 1 } }), + ), + Effect.provide( + httpClientLayer((request) => { + requests += 1; return Effect.succeed(jsonResponse(request, 200, [])); }), ), ), ); - expect(attempts).toBe(3); - expect(result).toEqual([]); + expect(Exit.isFailure(exit) && Cause.hasDies(exit.cause)).toBe(true); + expect(requests).toBe(0); + }); + + test("retries transport errors for GET requests", async () => { + const { requestTimes, result } = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request, attempt) => + attempt < 3 ? transportError(request, "socket reset") : jsonResponse(request, 200, []), + ); + yield* TestClock.adjust(Duration.minutes(1)); + return { requestTimes: call.requestTimes, result: yield* Fiber.join(call.fiber) }; + }), + ); + + expect(requestTimes).toHaveLength(3); + expect(result).toEqual(Exit.succeed([])); }); test("reveals redacted auth tokens only at the transport boundary", async () => { @@ -354,54 +597,6 @@ describe("makeSupabaseApiClient", () => { }); }); - test("retries 5xx responses for idempotent GET requests", async () => { - let attempts = 0; - - const result = await Effect.runPromise( - makeSupabaseApiClient(config).pipe( - Effect.flatMap((client) => - client.execute<"v1GetProject">(operationDefinitions.v1GetProject, { - ref: "abcdefghijklmnopqrst", - }), - ), - Effect.provide( - httpClientLayer((request) => { - attempts += 1; - if (attempts === 1) { - return Effect.succeed( - jsonResponse(request, 500, { - error: "temporary failure", - }), - ); - } - - return Effect.succeed( - jsonResponse(request, 200, { - id: "project-id", - ref: "abcdefghijklmnopqrst", - organization_id: "org-id", - organization_slug: "my-org", - name: "project-name", - region: "us-east-1", - created_at: "2026-03-13T12:00:00.000Z", - status: "ACTIVE_HEALTHY", - database: { - host: "db.supabase.internal", - version: "17.0.1", - postgres_engine: "17", - release_channel: "ga", - }, - }), - ); - }), - ), - ), - ); - - expect(attempts).toBe(2); - expect(result.database.host).toBe("db.supabase.internal"); - }); - test("decodes nullable JWT templates in API key responses", async () => { const result = await Effect.runPromise( makeSupabaseApiClient(config).pipe( @@ -735,28 +930,19 @@ describe("makeSupabaseApiClient", () => { }); test("stops after the configured number of transport retries", async () => { - let attempts = 0; - - const exit = await Effect.runPromise( - makeSupabaseApiClient(config, { - retry: { - maxRetries: 2, - }, - }).pipe( - Effect.flatMap((client) => - client.execute<"v1ListAllProjects">(operationDefinitions.v1ListAllProjects, {}), - ), - Effect.exit, - Effect.provide( - httpClientLayer((request) => { - attempts += 1; - return Effect.fail(transportError(request, "still broken")); - }), - ), - ), + const { requestTimes, exit } = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall( + listProjects, + (request) => transportError(request, "still broken"), + { retry: { maxRetries: 2 } }, + ); + yield* TestClock.adjust(Duration.minutes(1)); + return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) }; + }), ); - expect(attempts).toBe(3); + expect(requestTimes).toHaveLength(3); expect(Exit.isFailure(exit)).toBe(true); }); @@ -1228,3 +1414,330 @@ describe("makeSupabaseApiClient", () => { expect(requestUrlParam(seenRequest!, "filter")).toBeUndefined(); }); }); + +describe("retry policy", () => { + const createProject = (client: SupabaseApiClientShape) => + client.execute<"v1CreateAProject">(operationDefinitions.v1CreateAProject, { + db_pass: "hunter2", + name: "project-name", + organization_slug: "my-org", + }); + + const createdProject = (request: HttpClientRequest.HttpClientRequest) => + jsonResponse(request, 201, { + id: "project-id", + ref: "abcdefghijklmnopqrst", + organization_id: "org-id", + organization_slug: "my-org", + name: "project-name", + region: "us-east-1", + created_at: "2026-03-13T12:00:00.000Z", + status: "ACTIVE_HEALTHY", + }); + + const gaps = (times: ReadonlyArray) => times.slice(1).map((time, i) => time - times[i]!); + + test.each([500, 503])( + "waits out the backoff before retrying a GET after a %i", + async (status) => { + const { early, afterBackoff, exit } = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request, attempt) => + attempt === 1 ? jsonResponse(request, status, {}) : jsonResponse(request, 200, []), + ); + // The first backoff ceiling is 250ms, so a jitter draw of 0.5 waits 125ms. + yield* TestClock.adjust(Duration.millis(124)); + const early = call.requestTimes.length; + yield* TestClock.adjust(Duration.millis(1)); + const afterBackoff = call.requestTimes.length; + return { early, afterBackoff, exit: yield* Fiber.join(call.fiber) }; + }), + ); + + expect(early).toBe(1); + expect(afterBackoff).toBe(2); + expect(exit).toEqual(Exit.succeed([])); + }, + ); + + test("backs off exponentially up to the delay cap", async () => { + const requestTimes = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall( + listProjects, + (request) => jsonResponse(request, 503, {}), + { retry: { maxRetries: 8 } }, + ); + yield* TestClock.adjust(Duration.minutes(1)); + yield* Fiber.join(call.fiber); + return call.requestTimes; + }), + 1, + ); + + expect(gaps(requestTimes)).toEqual([250, 500, 1000, 2000, 4000, 4000, 4000, 4000]); + }); + + test("retries a POST after a 429 only once its Retry-After seconds have elapsed", async () => { + const { early, afterWait, exit } = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(createProject, (request, attempt) => + attempt === 1 + ? HttpClientResponse.fromWeb( + request, + new Response("{}", { status: 429, headers: { "retry-after": "2" } }), + ) + : createdProject(request), + ); + yield* TestClock.adjust(Duration.millis(1999)); + const early = call.requestTimes.length; + yield* TestClock.adjust(Duration.millis(1)); + const afterWait = call.requestTimes.length; + return { early, afterWait, exit: yield* Fiber.join(call.fiber) }; + }), + ); + + expect(early).toBe(1); + expect(afterWait).toBe(2); + expect(Exit.isSuccess(exit)).toBe(true); + }); + + test("retries a GET immediately after a 500 with Retry-After 0", async () => { + const requestTimes = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request, attempt) => + attempt === 1 + ? HttpClientResponse.fromWeb( + request, + new Response("{}", { status: 500, headers: { "retry-after": "0" } }), + ) + : jsonResponse(request, 200, []), + ); + yield* TestClock.adjust(Duration.millis(0)); + yield* Fiber.join(call.fiber); + return call.requestTimes; + }), + ); + + expect(gaps(requestTimes)).toEqual([0]); + }); + + test("honours an HTTP-date Retry-After on a 503", async () => { + const now = Date.parse("2026-10-13T12:00:00Z"); + const requestTimes = await onVirtualClock( + Effect.gen(function* () { + yield* TestClock.setTime(now); + const call = yield* startVirtualCall(listProjects, (request, attempt) => + attempt === 1 + ? HttpClientResponse.fromWeb( + request, + new Response("{}", { + status: 503, + headers: { "retry-after": "Tue, 13 Oct 2026 12:00:03 GMT" }, + }), + ) + : jsonResponse(request, 200, []), + ); + yield* TestClock.adjust(Duration.minutes(1)); + yield* Fiber.join(call.fiber); + return call.requestTimes; + }), + ); + + expect(gaps(requestTimes)).toEqual([3000]); + }); + + test.each(["soon", "-5", ""])( + "falls back to the backoff when Retry-After is %j", + async (retryAfter) => { + const requestTimes = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request, attempt) => + attempt === 1 + ? HttpClientResponse.fromWeb( + request, + new Response("{}", { status: 429, headers: { "retry-after": retryAfter } }), + ) + : jsonResponse(request, 200, []), + ); + yield* TestClock.adjust(Duration.minutes(1)); + yield* Fiber.join(call.fiber); + return call.requestTimes; + }), + ); + + expect(gaps(requestTimes)).toEqual([125]); + }, + ); + + test("returns a 429 whose Retry-After exceeds the cap without retrying", async () => { + const { requestTimes, exit } = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request) => + HttpClientResponse.fromWeb( + request, + new Response("{}", { status: 429, headers: { "retry-after": "30" } }), + ), + ); + yield* TestClock.adjust(Duration.minutes(1)); + return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) }; + }), + ); + + expect(requestTimes).toHaveLength(1); + const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; + if (!HttpClientError.isHttpClientError(error) || error.reason._tag !== "StatusCodeError") { + throw new Error("expected StatusCodeError"); + } + expect(error.reason.response.status).toBe(429); + }); + + test("honours a Retry-After exactly at the cap", async () => { + const requestTimes = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request, attempt) => + attempt === 1 + ? HttpClientResponse.fromWeb( + request, + new Response("{}", { status: 429, headers: { "retry-after": "10" } }), + ) + : jsonResponse(request, 200, []), + ); + yield* TestClock.adjust(Duration.minutes(1)); + yield* Fiber.join(call.fiber); + return call.requestTimes; + }), + ); + + expect(gaps(requestTimes)).toEqual([10_000]); + }); + + describe("per-request retry options", () => { + const listProjectsWith = (retry: SupabaseApiRetryOptions) => (client: SupabaseApiClientShape) => + client.execute<"v1ListAllProjects">(operationDefinitions.v1ListAllProjects, {}, { retry }); + + const failedStatus = (exit: Exit.Exit) => { + const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; + if (!HttpClientError.isHttpClientError(error) || error.reason._tag !== "StatusCodeError") { + throw new Error("expected StatusCodeError"); + } + return error.reason.response.status; + }; + + test("maxRetries 0 makes a single attempt and returns the 503", async () => { + const { requestTimes, exit } = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjectsWith({ maxRetries: 0 }), (request) => + jsonResponse(request, 503, {}), + ); + yield* TestClock.adjust(Duration.minutes(1)); + return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) }; + }), + ); + + expect(requestTimes).toHaveLength(1); + expect(failedStatus(exit)).toBe(503); + }); + + test("wins over the client-level retry options, field by field", async () => { + const { requestTimes, exit } = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall( + listProjectsWith({ maxRetries: 1 }), + (request) => jsonResponse(request, 503, {}), + { retry: { maxRetries: 4, requestTimeoutMs: 1000 } }, + ); + yield* TestClock.adjust(Duration.minutes(1)); + return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) }; + }), + ); + + expect(requestTimes).toHaveLength(2); + expect(failedStatus(exit)).toBe(503); + }); + + test("still applies when transformClient wraps the client", async () => { + const { requestTimes } = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall( + listProjectsWith({ maxRetries: 0 }), + (request) => jsonResponse(request, 503, {}), + { + transformClient: (client) => Effect.succeed(HttpClient.mapRequest(client, (r) => r)), + }, + ); + yield* TestClock.adjust(Duration.minutes(1)); + yield* Fiber.join(call.fiber); + return { requestTimes: call.requestTimes }; + }), + ); + + expect(requestTimes).toHaveLength(1); + }); + + test("requestTimeoutMs Infinity lets an attempt outlast the default timeout", async () => { + const { attempts, exit } = await onVirtualClock( + Effect.gen(function* () { + let attempts = 0; + const fiber = yield* makeSupabaseApiClient(config).pipe( + Effect.flatMap(listProjectsWith({ requestTimeoutMs: Number.POSITIVE_INFINITY })), + Effect.exit, + Effect.provide( + httpClientLayer((request) => + Effect.suspend(() => { + attempts += 1; + return Effect.as( + Effect.sleep(Duration.minutes(5)), + jsonResponse(request, 200, []), + ); + }), + ), + ), + Effect.forkChild, + ); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.minutes(5)); + return { attempts, exit: yield* Fiber.join(fiber) }; + }), + ); + + expect(attempts).toBe(1); + expect(exit).toEqual(Exit.succeed([])); + }); + }); + + test("does not retry a POST after a 500 however long it waits", async () => { + const requestTimes = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(createProject, (request) => + jsonResponse(request, 500, {}), + ); + yield* TestClock.adjust(Duration.minutes(1)); + yield* Fiber.join(call.fiber); + return call.requestTimes; + }), + ); + + expect(requestTimes).toHaveLength(1); + }); + + test("surfaces the last 503 once a GET exhausts its retries", async () => { + const { requestTimes, exit } = await onVirtualClock( + Effect.gen(function* () { + const call = yield* startVirtualCall(listProjects, (request) => + jsonResponse(request, 503, { message: "unavailable" }), + ); + yield* TestClock.adjust(Duration.minutes(1)); + return { requestTimes: call.requestTimes, exit: yield* Fiber.join(call.fiber) }; + }), + ); + + expect(requestTimes).toHaveLength(6); + const error = Exit.isFailure(exit) ? Cause.squash(exit.cause) : undefined; + expect(HttpClientError.isHttpClientError(error)).toBe(true); + if (!HttpClientError.isHttpClientError(error) || error.reason._tag !== "StatusCodeError") { + throw new Error("expected StatusCodeError"); + } + expect(error.reason.response.status).toBe(503); + }); +}); diff --git a/packages/stack/src/Artifacts.ts b/packages/stack/src/Artifacts.ts index 3ad3e92387..fe32209a1c 100644 --- a/packages/stack/src/Artifacts.ts +++ b/packages/stack/src/Artifacts.ts @@ -241,23 +241,23 @@ const definitions: Readonly> = { realtime: definition( "realtime", { - upstreamVersion: "v2.143.4", + upstreamVersion: "v2.144.0", revision: 0, image: - "ghcr.io/supabase/cli/realtime:v2.143.4-r0@sha256:902db0b55daecaf2f0c9fa50f4c73606be267d4f1eb004185be1bc00549ca2e6", - upstreamImage: "supabase/realtime:v2.143.4", + "ghcr.io/supabase/cli/realtime:v2.144.0-r0@sha256:220bccaef542dd8200071a14cd009f749ba422ccdce45cb6cdb93f57f260675e", + upstreamImage: "supabase/realtime:v2.144.0", natives: { "darwin-arm64": { - archive: "930c58118a6146bf6836935ff54cb9ca49f6cc0dfa2949e9a16c62d572a26a74", - manifest: "4183263200339bd30e241df1fa31235481d4a0d85560d541360a900511c46597", + archive: "8da707f0b27f22bfd25244f47d4a4471e2fbec3b0d596e1d334a645f254d0dec", + manifest: "8cc57d995fdb59f72377d889a87318937a234fb185a5c614a2d109994ca6d2f1", }, "linux-amd64": { - archive: "dfb1308c03a789228afdf2fd92b533efdccb08eec4c2e5801e70333ab7d78b75", - manifest: "caa2c5defee9dea6464fd3b818559b77a64dfec296418f33b37dc46c56682f58", + archive: "cb80975767fdb1a383cc518a29d63a3af5ee09f835b4ee7341c18f0be4dd6678", + manifest: "cc2c2ff383e22e7fe384ec4092766ea2caa380644bc7d13960b539d4b2cf7abd", }, "linux-arm64": { - archive: "0518d38ba40e844f3a1ae07ee1adf20b06f7f5cdcfe085825a4be6ec50eb9c17", - manifest: "dcf4883e6b868240b2129f2ee7b0e8525518769a671881d3a097a6803d94e2b5", + archive: "06eee5ce268be7877a9d27dd781117d60fd1b0836d915f19033ee256e01ed058", + manifest: "73483a0636dff15fd7a4f83f8e751d6a86b25f8681d7efd3deb08396f0229eb6", }, }, }, @@ -267,23 +267,23 @@ const definitions: Readonly> = { storage: definition( "storage", { - upstreamVersion: "v1.82.1", + upstreamVersion: "v1.83.3", revision: 0, image: - "ghcr.io/supabase/cli/storage:v1.82.1-r0@sha256:68c0f36557430219eb318df22ee817915992f2d5c976971e32574c3fe429be3c", - upstreamImage: "supabase/storage-api:v1.82.1", + "ghcr.io/supabase/cli/storage:v1.83.3-r0@sha256:ac03b8058499a192dc447cf7fc6db020f25230d12b282a49a6ba6367025354d0", + upstreamImage: "supabase/storage-api:v1.83.3", natives: { "darwin-arm64": { - archive: "69a3308f1633db400bc718ee363aaa3d0d8ae846f52af4ea3a61d4cf04064e69", - manifest: "37ac0f369085e88952fde5c7493e2291d9e1034ade6c763509900e4cafa7d978", + archive: "8208925fef22516668f700fb1fc80478fe2f80fa78452d65cda8862d7f277863", + manifest: "6349e482d794fb4d2aabdb347c4d6e6f62b920eef05e3da66e1b0f9826b196a4", }, "linux-amd64": { - archive: "f8716410f5997cf2a89cd3fe13565fe21b6bb239ef0be9818be385c0b397afba", - manifest: "ca82ac18ccccd91ec3370d65497c7f17c938f8601d7982a5fb48893ad5c1b159", + archive: "eda8dc7b0da3ac7b41c52dc87daec5a1868bd9529c156b947c9a53080bd68c03", + manifest: "754ad2082ffac3f420094c20730c7bb58f4df25382312b2471d45b0af3368f4b", }, "linux-arm64": { - archive: "45e623ac2913dc0aa2e11c3b58ea437227c51bc8e55a5184a2736fb1c13ab49e", - manifest: "024d11f58d90d54ebe62f55b88f75d5b0e541f1612211f5bf6a351472c6cb466", + archive: "8520036ba99639e6014a5e222fc858aaceae1aff575db392711d5cd382a227da", + manifest: "dcfc813c207bd501d4d2b87aa62ae8ce77ec8f3e676989ce49cb5eeee656ba78", }, }, }, @@ -418,23 +418,23 @@ const definitions: Readonly> = { analytics: definition( "analytics", { - upstreamVersion: "v1.53.0", - revision: 0, + upstreamVersion: "v1.50.15", + revision: 1, image: - "ghcr.io/supabase/cli/analytics:v1.53.0-r0@sha256:fad27128c4d39f85545ab3300c3e4a51bec5d1519c87bb75b7ab2926bba4fe90", - upstreamImage: "supabase/logflare:1.53.0", + "ghcr.io/supabase/cli/analytics:v1.50.15-r1@sha256:2b03ec3120effe5d33c1e2ec4570a932fb60ce6a6851b4920a22388b4e8356c1", + upstreamImage: "supabase/logflare:1.50.15", natives: { "darwin-arm64": { - archive: "37f7486fd2235330cbf173ef574aabb6d17cf2c2391e12febc7a1df2911a20a5", - manifest: "861578b203821d65c9ff6fde5c7a32c61630331c03f4be59afd9fe3e53237508", + archive: "abfc9ba64e354129e8f8b0f38b43747a5dab48785fd3593d11e52cd6e0ff37bf", + manifest: "60647643f5521089978dc20c6966b86ed939b9751b187cbb70e0de3fbd2479ac", }, "linux-amd64": { - archive: "ded6b2687a2eec39e1ea5175cd27f8e7bacc5af97d24b68e9468667d2ea77916", - manifest: "6a244ac0302231d78eb04fdc61f8aa64103f495381ee4f1d6a5bb05a6f5892c2", + archive: "7652456c1a1d73ac6fe10853bd8a38e79139b43fe796caec5725a3d0f225a3d8", + manifest: "6c465af69e26ce8a9c15988d5dcbbae069e9a92b697962bfa86c4f3e601b8409", }, "linux-arm64": { - archive: "e9c7846f8ca37c9321fdd1e1588cba71f15bd516797d163024a929d056992a8b", - manifest: "6b9f3bc6cc760372ddc272e373894abacfbafeb96c044caf7cd7c51a0d8025eb", + archive: "a75625d7c903d70e749b5ee776e2c5c98d51ee60ff92b28bb6684121fe0f3888", + manifest: "ca2569304cf24e6abd4242b55dd0d20338f40532fbe6377a9accdc5d47ea47ab", }, }, }, diff --git a/tools/oxlint/supabase-plugin.js b/tools/oxlint/supabase-plugin.js new file mode 100644 index 0000000000..4b564140a9 --- /dev/null +++ b/tools/oxlint/supabase-plugin.js @@ -0,0 +1,61 @@ +/** + * Oxlint plugin that keeps Management API calls on the typed `@supabase/api` client. + * + * `no-raw-management-api-url` flags string and template literals that start a `/v1/`, `/v2/` or + * `/platform/` path because such requests skip the `api.operation` span that per-command route + * detection reads. + */ + +const MESSAGE = + "Build Management API requests with the typed `@supabase/api` client (`api.v1.*`, `execute`/`executeRaw`) so the call emits the `api.operation` span; do not assemble `/v1/`, `/v2/` or `/platform/` URLs by hand."; + +// OTLP exporters use `/v1/traces`, `/v1/metrics` and `/v1/logs`, which are not Management API +// routes. +const OTLP_SIGNAL = "(?!v1/(?:traces|metrics|logs)(?:/|$))"; + +// A path that begins at its first segment with a Management API prefix, optionally behind an +// absolute origin. Service-scoped prefixes such as `/auth/v1/` and `/storage/v1/` start with a +// different segment and never match. +const LEADING_PATH = new RegExp(`^(?:https?://[^/]*)?/${OTLP_SIGNAL}(?:v[12]|platform)(?:/|$)`); + +// The same path continuing a string whose base was interpolated or concatenated before it. +const CONTINUED_PATH = new RegExp(`^/${OTLP_SIGNAL}(?:v[12]|platform)(?:/|$)`); + +function quasiText(quasi) { + return quasi.value.cooked ?? quasi.value.raw; +} + +const rule = { + meta: { + type: "problem", + docs: { + description: + "Disallow hand-built Management API URLs outside the typed `@supabase/api` client.", + }, + }, + create(context) { + const report = (node) => context.report({ node, message: MESSAGE }); + + return { + Literal(node) { + if (typeof node.value === "string" && LEADING_PATH.test(node.value)) { + report(node); + } + }, + TemplateLiteral(node) { + const matches = node.quasis.some((quasi, index) => { + const text = quasiText(quasi); + return index === 0 ? LEADING_PATH.test(text) : CONTINUED_PATH.test(text); + }); + if (matches) { + report(node); + } + }, + }; + }, +}; + +export default { + meta: { name: "supabase" }, + rules: { "no-raw-management-api-url": rule }, +};