diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 61aeffb7d7..06a00bcff6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -451,7 +451,7 @@ jobs: - name: Run backend integration tests env: VITEST_SHARD: ${{ matrix.shard }} - run: bun run supabase:with-env -- bunx vitest run --exclude=tests/*.unit.test.ts --exclude=tests/cli* --exclude=tests/read-replica-schema-catalog.test.ts --exclude=tests/updates* --exclude=tests/stats* --exclude=tests/channel_self* --exclude=tests/channel-rate-limit.test.ts $(sed 's|^|--exclude=|' tests/tinbase-db-tests.txt | tr '\n' ' ') --shard="$VITEST_SHARD" + run: bun run supabase:with-env -- bunx vitest run --exclude=tests/*.unit.test.ts --exclude=tests/cli* --exclude=tests/published-cli-rpc-contract.test.ts --exclude=tests/read-replica-schema-catalog.test.ts --exclude=tests/updates* --exclude=tests/stats* --exclude=tests/channel_self* --exclude=tests/channel-rate-limit.test.ts $(sed 's|^|--exclude=|' tests/tinbase-db-tests.txt | tr '\n' ' ') --shard="$VITEST_SHARD" - name: Show Edge server logs after backend test failure if: failure() && steps.bootstrap_edge_server.outputs.log_path != '' run: | @@ -845,7 +845,7 @@ jobs: - name: Run Cloudflare Workers integration tests env: VITEST_SHARD: ${{ matrix.shard }} - run: bun run supabase:with-env -- bunx vitest run --exclude=tests/cli* --exclude=tests/*.unit.test.ts $(sed 's|^|--exclude=|' tests/tinbase-db-tests.txt | tr '\n' ' ') --config vitest.config.cloudflare.ts --shard="$VITEST_SHARD" + run: bun run supabase:with-env -- bunx vitest run --exclude=tests/cli* --exclude=tests/published-cli-rpc-contract.test.ts --exclude=tests/*.unit.test.ts $(sed 's|^|--exclude=|' tests/tinbase-db-tests.txt | tr '\n' ' ') --config vitest.config.cloudflare.ts --shard="$VITEST_SHARD" - name: Show Cloudflare Worker logs after Cloudflare test failure if: failure() && steps.start_cloudflare_workers.outputs.log_path != '' run: | @@ -1524,3 +1524,88 @@ jobs: fi done bun scripts/supabase-worktree.ts stop --no-backup || true + + published_cli_contract: + needs: changes + if: needs.changes.outputs.run_cli == 'true' || needs.changes.outputs.run_capgo == 'true' + runs-on: ubuntu-latest + timeout-minutes: 15 + name: CRITICAL — Published CLI / do not break old CLI + permissions: + contents: read + concurrency: + group: capgo-published-cli-contract-${{ github.event_name }}-${{ github.repository }}-${{ github.head_ref || github.ref_name }} + cancel-in-progress: false + env: + SUPABASE_WORKTREE_INSTANCE: published-cli-${{ github.run_id }}-${{ github.run_attempt }} + SUPABASE_WORKTREE_PORT_OFFSET: 6100 + steps: + - name: Setup Node.js + uses: actions/setup-node@v6 + with: + node-version: 24.x + - name: Checkout repository + uses: actions/checkout@v6 + with: + fetch-depth: 0 + - name: Cache Deno dependencies + uses: actions/cache@v5 # v5 + with: + path: ${{ env.DENO_DIR }} + key: deno-${{ runner.os }}-${{ hashFiles('supabase/functions/deno.lock') }} + restore-keys: | + deno-${{ runner.os }}- + - name: Setup bun + run: bash scripts/setup-bun.sh + - name: Install Supabase CLI + uses: supabase/setup-cli@v2 + with: + version: 2.109.1 + - name: Link Supabase templates + run: ln -sfn supabase/templates templates + - name: Stop this job's Supabase stack + run: bun scripts/supabase-worktree.ts stop --no-backup || true + - name: Install dependencies + run: bun install + - name: Verify published CLI tag exists + run: | + if ! git tag -l 'cli-*' | grep -Eq '^cli-[0-9]'; then + echo '::error::No cli-* tag found for published CLI contract' + exit 1 + fi + - name: Run Supabase Start + run: bun scripts/supabase-worktree.ts start -x imgproxy,studio,mailpit,realtime,postgres-meta,supavisor,logflare,vector + - name: Reset isolated Supabase database + run: bun run supabase:db:reset + - name: Export isolated Supabase endpoints + run: | + bun scripts/supabase-worktree.ts with-env bun -e 'const apiUrl = new URL(process.env.SUPABASE_URL ?? ""); const dbUrl = process.env.SUPABASE_DB_URL ?? ""; if (!apiUrl.host || !dbUrl) throw new Error("Isolated Supabase status is missing API_URL or DB_URL."); console.log(`SUPABASE_FUNCTIONS_HEALTH_URL=http-get://${apiUrl.host}/functions/v1/ok`); console.log(`MAIN_SUPABASE_DB_URL=${dbUrl}`)' >> "$GITHUB_ENV" + - id: bootstrap_published_cli_edge_server + name: Bootstrap Edge server + env: + BACKGROUND_SERVICE_NAME: Bootstrap Edge server + BACKGROUND_RUN_COMMAND: exec bun scripts/supabase-worktree.ts functions serve + BACKGROUND_LOG_PATH: ${{ runner.temp }}/published-cli-edge-server.log + BACKGROUND_WAIT_TIMEOUT_MS: 60000 + run: | + export BACKGROUND_WAIT_ON="${SUPABASE_FUNCTIONS_HEALTH_URL:?Missing isolated Supabase health URL}" + bash .github/scripts/start-background-service.sh + - name: Run published CLI contract unit checks + run: bunx vitest run tests/published-cli-rpc-contract.unit.test.ts + - name: Run published CLI contract against PR schema + run: bun run supabase:with-env -- bunx vitest run tests/published-cli-rpc-contract.test.ts --no-file-parallelism --maxWorkers=1 + - name: Show published CLI Edge server logs after failure + if: failure() && steps.bootstrap_published_cli_edge_server.outputs.log_path != '' + run: | + echo "::group::Published CLI Bootstrap Edge server log" + tail -n 200 "${{ steps.bootstrap_published_cli_edge_server.outputs.log_path }}" || true + echo "::endgroup::" + - name: Stop published CLI background services + if: always() + run: | + for pid in "${{ steps.bootstrap_published_cli_edge_server.outputs.pid }}"; do + if [ -n "${pid}" ] && kill -0 "${pid}" 2>/dev/null; then + kill "${pid}" 2>/dev/null || true + fi + done + bun scripts/supabase-worktree.ts stop --no-backup || true diff --git a/AGENTS.md b/AGENTS.md index 7f75d54d40..c024b363ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,42 @@ This file provides guidance to AI agents (Claude Code, Cursor, Copilot, etc.) when working with code in this repository. +## MUST NOT — never break already-published CLI versions + +**We cannot break `@capgo/cli` releases that customers already run in production.** + +The last published CLI is the newest `cli-` git tag (and the matching +`@capgo/cli` version on npm when it exists). Customers do not upgrade the CLI on +every backend deploy. + +### You MUST NOT + +- Revoke `GRANT` / `EXECUTE` on an RPC that the last published CLI still calls + (for example `.rpc('get_user_id', { apikey })` on the anonymous API-key path). +- Change backend identity resolution in ways that break the last published CLI + without shipping a CLI release that stops using the old path first. +- Make CI pass by **inverting** published-CLI contract tests to expect permission + denied (`42501`). Those tests assert **success** — that is the contract. +- Test only the PR-branch workspace CLI when validating RPC/grant changes. CI must + exercise the **published** npm CLI against the PR schema. + +### Before you revoke anon/service access on a CLI-facing RPC + +1. Ship a CLI release that no longer calls it. +2. Wait for customers to upgrade (the `cli-*` tag must reflect the new behavior). +3. Only then revoke or harden the RPC. + +### Where this is enforced + +- CI job: **`CRITICAL — Published CLI / do not break old CLI`** +- Tests: `tests/published-cli-rpc-contract.test.ts` (live contract; do not invert) +- Helpers: `scripts/published-cli-contract.ts` (parses `.rpc('...')` from the last `cli-*` tag) + +Org-perm / invite **oracle** RPCs (`invite_user_to_org_rbac`, `get_org_perm_for_apikey*`, +`get_user_id(text,text)`, …) stay revoked for anonymous callers — see +`tests/security-oracle-rpc-hardening.test.ts`. That is separate from the published +CLI identity path (`get_user_id(text)` with a valid API key must keep working). + ## Essential Development Commands ### Building and Development diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e777e89dad..16463c6e26 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,30 @@ the relevant section before making your contribution. It will make it a lot easier for us maintainers and smooth out the experience for all involved. The community looks forward to your contributions. 🎉 +## Do not break already-published CLI versions + +**Never ship backend or database changes that break the Capgo CLI version customers +already have installed.** + +The CLI on customer machines is whatever was last published (`cli-` git tags +and `@capgo/cli` on npm). It does not update when we deploy the API or database. + +If you change RPC grants, revoke `EXECUTE`, or alter API-key identity behavior, you +must keep the **last published CLI** working until a new CLI release stops using the +old path and customers have had time to upgrade. + +CI enforces this in the job **`CRITICAL — Published CLI / do not break old CLI`** +(`tests/published-cli-rpc-contract.test.ts`). That job runs the **published** npm CLI +against your PR schema — not only the CLI built from your branch. Do not “fix” failing +contract tests by expecting permission denied; they are supposed to pass. + +Typical regression: revoking `get_user_id(text)` for `anon` while published CLI still +calls `.rpc('get_user_id', { apikey })` with a valid API key. That broke production in +the past; the contract tests exist so it cannot happen again. + +Oracle hardening (blocking anonymous callers from org-perm / invite RPCs) is intentional +and separate — see `tests/security-oracle-rpc-hardening.test.ts`. + ## Running tests locally This project uses a custom test runner located in diff --git a/scripts/published-cli-contract.ts b/scripts/published-cli-contract.ts new file mode 100644 index 0000000000..a8be214bfc --- /dev/null +++ b/scripts/published-cli-contract.ts @@ -0,0 +1,433 @@ +import { execFileSync } from 'node:child_process' +import { compare, valid as isValidSemver } from 'semver' + +export const PUBLISHED_CLI_TAG_PREFIX = 'cli-' +export const PUBLISHED_CLI_TAG_PATTERN = /^cli-[0-9]/ +export const PUBLISHED_CLI_RPC_PATTERN = /\.rpc\(\s*['"`]([a-z][a-z0-9_]*)['"`]/g + +export interface PublishedCliRpcCall { + name: string + /** null when the RPC argument expression cannot be resolved statically (e.g. a variable). */ + argKeys: string[] | null +} + +export type GitRunner = (args: string[]) => string + +function defaultGitRunner(args: string[]): string { + return execFileSync('git', args, { encoding: 'utf8' }).trim() +} + +export function comparePublishedCliTags(left: string, right: string): number { + const leftVersion = left.slice(PUBLISHED_CLI_TAG_PREFIX.length) + const rightVersion = right.slice(PUBLISHED_CLI_TAG_PREFIX.length) + + if (isValidSemver(leftVersion) && isValidSemver(rightVersion)) + return compare(leftVersion, rightVersion) + + const parse = (tag: string) => tag.replace(PUBLISHED_CLI_TAG_PREFIX, '').split(/[.-]/).map(part => Number.parseInt(part, 10) || 0) + const leftParts = parse(left) + const rightParts = parse(right) + const length = Math.max(leftParts.length, rightParts.length) + + for (let index = 0; index < length; index++) { + const delta = (leftParts[index] ?? 0) - (rightParts[index] ?? 0) + if (delta !== 0) + return delta + } + + return 0 +} + +export function resolveLatestPublishedCliTag(runGit: GitRunner = defaultGitRunner): string { + const output = runGit(['tag', '-l', `${PUBLISHED_CLI_TAG_PREFIX}*`]) + const tags = output + .split('\n') + .map(tag => tag.trim()) + .filter(tag => PUBLISHED_CLI_TAG_PATTERN.test(tag)) + + if (tags.length === 0) + throw new Error(`No published CLI tags found (expected ${PUBLISHED_CLI_TAG_PREFIX})`) + + return tags.sort(comparePublishedCliTags).at(-1)! +} + +export function resolvePublishedCliNpmVersion(tag: string): string { + if (!PUBLISHED_CLI_TAG_PATTERN.test(tag)) + throw new Error(`Expected a published CLI tag, got ${tag}`) + + return tag.slice(PUBLISHED_CLI_TAG_PREFIX.length) +} + +type NpmRunner = (args: string[]) => string + +function defaultNpmRunner(args: string[]): string { + return execFileSync('npm', args, { encoding: 'utf8' }).trim() +} + +export function normalizePublishedCliNpmVersions(parsed: unknown): string[] { + if (Array.isArray(parsed)) + return parsed.filter((version): version is string => typeof version === 'string') + + if (typeof parsed === 'string') + return [parsed] + + throw new Error(`Unexpected @capgo/cli versions response: ${JSON.stringify(parsed)}`) +} + +export function resolvePublishedCliNpmInstallVersion( + tag: string, + runNpm: NpmRunner = defaultNpmRunner, +): string { + const targetVersion = resolvePublishedCliNpmVersion(tag) + const publishedVersions = normalizePublishedCliNpmVersions(JSON.parse(runNpm(['view', '@capgo/cli', 'versions', '--json']))) + + if (publishedVersions.includes(targetVersion)) + return targetVersion + + const installable = publishedVersions + .filter(version => comparePublishedCliTags(`${PUBLISHED_CLI_TAG_PREFIX}${version}`, tag) <= 0) + .sort((left, right) => comparePublishedCliTags(`${PUBLISHED_CLI_TAG_PREFIX}${left}`, `${PUBLISHED_CLI_TAG_PREFIX}${right}`)) + .at(-1) + + if (!installable) { + throw new Error( + `No published @capgo/cli npm version found for git tag ${tag}. ` + + `Latest npm release is ${publishedVersions.at(-1) ?? 'unknown'}.`, + ) + } + + return installable +} + +export function resolvePublishedCliRpcSourceTag( + latestTag: string, + npmInstallVersion: string, + runGit: GitRunner = defaultGitRunner, +): string { + const latestVersion = resolvePublishedCliNpmVersion(latestTag) + if (latestVersion === npmInstallVersion) + return latestTag + + const installTag = `${PUBLISHED_CLI_TAG_PREFIX}${npmInstallVersion}` + try { + runGit(['rev-parse', '-q', '--verify', `refs/tags/${installTag}`]) + return installTag + } + catch { + throw new Error( + `Published CLI npm version ${npmInstallVersion} has no matching git tag ${installTag}. ` + + `Cannot verify RPC contract without the CLI source for the package under test.`, + ) + } +} + +function findMatchingCloseParen(source: string, openParenIndex: number): number { + let depth = 0 + let inString: '"' | '\'' | '`' | null = null + let escaped = false + + for (let index = openParenIndex; index < source.length; index++) { + const char = source[index] + + if (inString) { + if (escaped) { + escaped = false + continue + } + if (char === '\\') { + escaped = true + continue + } + if (char === inString) + inString = null + continue + } + + if (char === '"' || char === '\'' || char === '`') { + inString = char + continue + } + + if (char === '(') + depth++ + else if (char === ')') { + depth-- + if (depth === 0) + return index + } + } + + return -1 +} + +function extractKeysFromObjectLiteral(source: string, objectStart: number): string[] { + let depth = 0 + let inString: '"' | '\'' | '`' | null = null + let escaped = false + + for (let index = objectStart; index < source.length; index++) { + const char = source[index] + + if (inString) { + if (escaped) { + escaped = false + continue + } + if (char === '\\') { + escaped = true + continue + } + if (char === inString) + inString = null + continue + } + + if (char === '"' || char === '\'' || char === '`') { + inString = char + continue + } + + if (char === '{') { + depth++ + continue + } + + if (char === '}') { + depth-- + if (depth === 0) { + const argsSource = source.slice(objectStart + 1, index) + const keys = new Set() + for (const segment of splitTopLevelArgSegments(argsSource)) { + const trimmed = segment.trim() + if (!trimmed) + continue + + const explicit = trimmed.match(/^([a-zA-Z_][\w]*)\s*:/) + if (explicit) { + keys.add(explicit[1]!) + continue + } + + const shorthand = trimmed.match(/^([a-zA-Z_][\w]*)$/) + if (shorthand) + keys.add(shorthand[1]!) + } + + return [...keys].sort() + } + } + } + + return [] +} + +function extractArgKeysFromRpcArgExpression(source: string, argStart: number, rpcCloseParen: number): string[] { + const keys = new Set() + let inString: '"' | '\'' | '`' | null = null + let escaped = false + let braceDepth = 0 + + for (let index = argStart; index < rpcCloseParen; index++) { + const char = source[index] + + if (inString) { + if (escaped) { + escaped = false + continue + } + if (char === '\\') { + escaped = true + continue + } + if (char === inString) + inString = null + continue + } + + if (char === '"' || char === '\'' || char === '`') { + inString = char + continue + } + + if (char === '{') { + if (braceDepth === 0) { + for (const key of extractKeysFromObjectLiteral(source, index)) + keys.add(key) + } + braceDepth++ + continue + } + + if (char === '}') + braceDepth-- + } + + return [...keys].sort() +} + +export function extractArgKeysFromRpcCall(source: string, afterRpcNameIndex: number): string[] | null { + const rpcOpenParenIndex = source.lastIndexOf('(', afterRpcNameIndex) + if (rpcOpenParenIndex === -1) + return [] + + const rpcCloseParen = findMatchingCloseParen(source, rpcOpenParenIndex) + if (rpcCloseParen === -1) + return [] + + let cursor = afterRpcNameIndex + const castMatch = source.slice(cursor).match(/^\s*as\s+any/) + if (castMatch) + cursor += castMatch[0].length + + const remainder = source.slice(cursor).trimStart() + if (remainder.startsWith(')')) + return [] + + const commaMatch = source.slice(cursor).match(/^\s*,/) + if (!commaMatch) + return null + + cursor += commaMatch[0].length + const afterComma = source.slice(cursor).trimStart() + if (afterComma.startsWith('{')) { + const objectStart = source.indexOf('{', cursor) + if (objectStart === -1 || objectStart >= rpcCloseParen) + return null + + return extractKeysFromObjectLiteral(source, objectStart) + } + + const keys = extractArgKeysFromRpcArgExpression(source, cursor, rpcCloseParen) + if (keys.length > 0) + return keys + + const argExpression = source.slice(cursor, rpcCloseParen).trim() + return argExpression ? null : [] +} + +function splitTopLevelArgSegments(source: string): string[] { + const segments: string[] = [] + let start = 0 + let depth = 0 + let inString: '"' | '\'' | '`' | null = null + let escaped = false + + for (let index = 0; index < source.length; index++) { + const char = source[index] + + if (inString) { + if (escaped) { + escaped = false + continue + } + if (char === '\\') { + escaped = true + continue + } + if (char === inString) + inString = null + continue + } + + if (char === '"' || char === '\'' || char === '`') { + inString = char + continue + } + + if (char === '{' || char === '[' || char === '(') + depth++ + else if (char === '}' || char === ']' || char === ')') + depth-- + else if (char === ',' && depth === 0) { + segments.push(source.slice(start, index)) + start = index + 1 + } + } + + if (start < source.length) + segments.push(source.slice(start)) + + return segments +} + +function formatPublishedCliRpcCallKey(call: PublishedCliRpcCall): string { + if (call.argKeys === null) + return `${call.name}(?)` + return `${call.name}(${call.argKeys.join(',')})` +} + +function comparePublishedCliRpcCalls(left: PublishedCliRpcCall, right: PublishedCliRpcCall): number { + const byName = left.name.localeCompare(right.name) + if (byName !== 0) + return byName + return formatPublishedCliRpcCallKey(left).localeCompare(formatPublishedCliRpcCallKey(right)) +} + +function rpcNameEndIndex(source: string, match: RegExpMatchArray): number { + return (match.index ?? 0) + match[0].length +} + +export function extractPublishedCliRpcCallsFromSource(source: string): PublishedCliRpcCall[] { + const calls = new Map() + + for (const match of source.matchAll(PUBLISHED_CLI_RPC_PATTERN)) { + const name = match[1]! + const argKeys = extractArgKeysFromRpcCall(source, rpcNameEndIndex(source, match)) + const call = { name, argKeys } + calls.set(formatPublishedCliRpcCallKey(call), call) + } + + return [...calls.values()].sort(comparePublishedCliRpcCalls) +} + +export function extractPublishedCliRpcCalls(tag: string, runGit: GitRunner = defaultGitRunner): PublishedCliRpcCall[] { + const output = runGit(['grep', '-l', '-E', String.raw`\.rpc\(["'\`][a-z][a-z0-9_]*["'\`]`, tag, '--', 'cli/src']) + const files = output + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + .map((line) => { + const tagPrefix = `${tag}:` + return line.startsWith(tagPrefix) ? line.slice(tagPrefix.length) : line + }) + + const calls = new Map() + for (const file of files) { + const source = runGit(['show', `${tag}:${file}`]) + for (const call of extractPublishedCliRpcCallsFromSource(source)) { + calls.set(formatPublishedCliRpcCallKey(call), call) + } + } + + return [...calls.values()].sort(comparePublishedCliRpcCalls) +} + +export function formatPublishedCliRpcCall(call: PublishedCliRpcCall): string { + if (call.argKeys === null) + return `${call.name}()` + if (call.argKeys.length === 0) + return `${call.name}()` + return `${call.name}({ ${call.argKeys.join(', ')} })` +} + +export function rpcCallMatchesOverload( + call: PublishedCliRpcCall, + argNames: string[] | null, + defaultCount: number, + argCount: number, +): boolean { + if (call.argKeys === null) + return true + + const names = argNames ?? [] + + if (call.argKeys.length === 0) + return argCount === 0 || defaultCount === argCount + + const provided = new Set(call.argKeys) + if (!call.argKeys.every(key => names.includes(key))) + return false + + const requiredNames = names.slice(0, Math.max(argCount - defaultCount, 0)) + return requiredNames.every(name => provided.has(name)) +} diff --git a/tests/published-cli-rpc-contract.test.ts b/tests/published-cli-rpc-contract.test.ts new file mode 100644 index 0000000000..6aa041d9be --- /dev/null +++ b/tests/published-cli-rpc-contract.test.ts @@ -0,0 +1,198 @@ +/** + * CRITICAL — Published CLI RPC contract (do not break old CLI) + * + * We cannot break already-published @capgo/cli versions that customers still run. + * This file is the live guard for #3189-style regressions: if a migration revokes + * anon EXECUTE on an RPC the last published CLI still calls (for example + * `.rpc('get_user_id', { apikey })`), CI MUST fail here. + * + * CI job: CRITICAL — Published CLI / do not break old CLI + * Do NOT invert these tests to expect permission denied (42501). Success is the contract. + */ +import type { Database } from '../src/types/supabase.types' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { createClient } from '@supabase/supabase-js' +import { Pool } from 'pg' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + extractPublishedCliRpcCalls, + formatPublishedCliRpcCall, + resolveLatestPublishedCliTag, + resolvePublishedCliNpmInstallVersion, + resolvePublishedCliRpcSourceTag, + rpcCallMatchesOverload, + type PublishedCliRpcCall, +} from '../scripts/published-cli-contract.ts' +import { + APIKEY_TEST_ORG_SUPER_ADMIN, + normalizeLocalhostUrl, + POSTGRES_URL, + USER_ID, +} from './test-utils' + +const execFileAsync = promisify(execFile) +const SUPABASE_URL = normalizeLocalhostUrl(process.env.SUPABASE_URL)! +const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY as string + +interface FunctionPrivilegeRow { + proc: string + argNames: string[] | null + defaultCount: number + argCount: number + anonExec: boolean +} + +interface PublishedCliContractContext { + tag: string + npmVersion: string + rpcSourceTag: string + rpcCalls: PublishedCliRpcCall[] +} + +let publishedCliContract: PublishedCliContractContext | undefined + +function loadPublishedCliContract(): PublishedCliContractContext { + if (!publishedCliContract) { + const tag = resolveLatestPublishedCliTag() + const npmVersion = resolvePublishedCliNpmInstallVersion(tag) + const rpcSourceTag = resolvePublishedCliRpcSourceTag(tag, npmVersion) + publishedCliContract = { + tag, + npmVersion, + rpcSourceTag, + rpcCalls: extractPublishedCliRpcCalls(rpcSourceTag), + } + } + + return publishedCliContract +} + +async function loadFunctionPrivileges(pool: Pool, functionName: string): Promise { + const result = await pool.query(` + SELECT + p.oid::regprocedure::text AS proc, + CASE + WHEN p.proargnames IS NULL THEN NULL + WHEN p.proargmodes IS NULL THEN p.proargnames + ELSE ARRAY( + SELECT name + FROM unnest(p.proargnames, p.proargmodes) AS a(name, mode) + WHERE a.mode IN ('i', 'b', 'v') + ) + END AS "argNames", + COALESCE(p.pronargdefaults, 0) AS "defaultCount", + p.pronargs AS "argCount", + has_function_privilege('anon', p.oid, 'EXECUTE') AS "anonExec" + FROM pg_proc AS p + INNER JOIN pg_namespace AS n ON n.oid = p.pronamespace + WHERE n.nspname = 'public' + AND p.prokind = 'f' + AND p.proname = $1 + ORDER BY 1 + `, [functionName]) + + return result.rows +} + +function resolveMatchingOverloads(call: PublishedCliRpcCall, rows: FunctionPrivilegeRow[]) { + if (call.argKeys === null) + return rows + + return rows.filter(row => rpcCallMatchesOverload( + call, + row.argNames, + Number(row.defaultCount), + Number(row.argCount), + )) +} + +function createAnonymousApiKeyClient(apikey: string) { + return createClient(SUPABASE_URL, SUPABASE_ANON_KEY, { + global: { + headers: { + capgkey: apikey, + }, + }, + auth: { + persistSession: false, + }, + }) +} + +describe('CRITICAL published CLI RPC contract', () => { + let pool: Pool + let contract: PublishedCliContractContext + + beforeAll(() => { + contract = loadPublishedCliContract() + pool = new Pool({ connectionString: POSTGRES_URL }) + expect(contract.rpcCalls.length, `No RPC calls extracted from ${contract.rpcSourceTag}`).toBeGreaterThan(0) + }) + + afterAll(async () => { + await pool.end() + }) + + it.concurrent('keeps anon EXECUTE on every RPC still called by the published CLI', async () => { + const missing: string[] = [] + + for (const call of contract.rpcCalls) { + const overloads = await loadFunctionPrivileges(pool, call.name) + expect(overloads.length, `${call.name} is missing from public schema`).toBeGreaterThan(0) + + const matches = resolveMatchingOverloads(call, overloads) + if (call.argKeys !== null) + expect(matches.length, `No overload matched ${formatPublishedCliRpcCall(call)}`).toBeGreaterThan(0) + + const overloadsToCheck = call.argKeys === null ? overloads : matches + const hasAnonExec = overloadsToCheck.some(overload => overload.anonExec) + + if (!hasAnonExec) { + const procList = overloadsToCheck + .map(overload => overload.proc) + .join(', ') + missing.push(`${procList || call.name} required by ${formatPublishedCliRpcCall(call)}`) + } + } + + expect(missing, `Published CLI ${contract.rpcSourceTag} would break in production:\n${missing.join('\n')}`).toEqual([]) + }) + + it.concurrent(`published CLI identity RPC get_user_id({ apikey }) MUST succeed for valid API keys`, async () => { + const client = createAnonymousApiKeyClient(APIKEY_TEST_ORG_SUPER_ADMIN) + + const { data, error } = await client.rpc('get_user_id', { + apikey: APIKEY_TEST_ORG_SUPER_ADMIN, + }) + + expect(error, 'Do not expect permission denied — published CLI customers rely on this RPC').toBeNull() + expect(data).toBe(USER_ID) + }) + + it.concurrent('published @capgo/cli app list MUST succeed against this schema', async () => { + const { stdout, stderr } = await execFileAsync('bunx', [ + `@capgo/cli@${contract.npmVersion}`, + 'app', + 'list', + '-a', + APIKEY_TEST_ORG_SUPER_ADMIN, + '--supa-host', + SUPABASE_URL, + '--supa-anon', + SUPABASE_ANON_KEY, + '--output-text', + ], { + timeout: 180_000, + env: { + ...process.env, + CI: 'true', + NO_COLOR: '1', + }, + }) + + const output = `${stdout}\n${stderr}` + expect(output).not.toMatch(/permission denied/i) + expect(output).not.toMatch(/42501/) + }, 180_000) +}) diff --git a/tests/published-cli-rpc-contract.unit.test.ts b/tests/published-cli-rpc-contract.unit.test.ts new file mode 100644 index 0000000000..2b5258a083 --- /dev/null +++ b/tests/published-cli-rpc-contract.unit.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it } from 'vitest' +import { + comparePublishedCliTags, + extractPublishedCliRpcCallsFromSource, + normalizePublishedCliNpmVersions, + resolveLatestPublishedCliTag, + resolvePublishedCliNpmInstallVersion, + resolvePublishedCliRpcSourceTag, + rpcCallMatchesOverload, +} from '../scripts/published-cli-contract.ts' + +describe('published CLI contract helpers', () => { + it.concurrent('sorts cli tags by semver', () => { + expect(comparePublishedCliTags('cli-8.42.4', 'cli-8.42.5')).toBeLessThan(0) + expect(comparePublishedCliTags('cli-8.42.5', 'cli-8.42.5')).toBe(0) + expect(comparePublishedCliTags('cli-8.43.0', 'cli-8.42.5')).toBeGreaterThan(0) + expect(comparePublishedCliTags('cli-8.42.5', 'cli-8.42.5-rc.1')).toBeGreaterThan(0) + }) + + it.concurrent('prefers stable cli tags over prerelease tags with the same version', () => { + const tag = resolveLatestPublishedCliTag((args) => { + if (args[0] === 'tag') + return 'cli-8.42.5-rc.1\ncli-8.42.5\ncli-8.42.4' + throw new Error(`Unexpected git call: ${args.join(' ')}`) + }) + + expect(tag).toBe('cli-8.42.5') + }) + + it.concurrent('uses the git tag matching the npm package under test for rpc extraction', () => { + const sourceTag = resolvePublishedCliRpcSourceTag('cli-8.42.5', '8.42.3', (args) => { + if (args[0] === 'rev-parse') + return 'ok' + throw new Error(`Unexpected git call: ${args.join(' ')}`) + }) + + expect(sourceTag).toBe('cli-8.42.3') + }) + + it.concurrent('ignores cli-helper tags when resolving the latest published CLI tag', () => { + const tag = resolveLatestPublishedCliTag((args) => { + if (args[0] === 'tag') + return 'cli-helper-1.1.1-rc.1\ncli-8.42.4\ncli-8.42.5' + throw new Error(`Unexpected git call: ${args.join(' ')}`) + }) + + expect(tag).toBe('cli-8.42.5') + }) + + it.concurrent('normalizes single-string npm versions responses', () => { + expect(normalizePublishedCliNpmVersions('8.42.3')).toEqual(['8.42.3']) + expect(normalizePublishedCliNpmVersions(['8.42.1', '8.42.3'])).toEqual(['8.42.1', '8.42.3']) + }) + + it.concurrent('uses the highest published npm version at or below the git tag', () => { + const version = resolvePublishedCliNpmInstallVersion('cli-8.42.5', (args) => { + expect(args).toEqual(['view', '@capgo/cli', 'versions', '--json']) + return JSON.stringify(['8.42.1', '8.42.2', '8.42.3']) + }) + + expect(version).toBe('8.42.3') + }) + + it.concurrent('extracts rpc calls and argument keys from CLI source', () => { + const source = ` + await supabase.rpc('get_user_id', { apikey }).single() + await supabase.rpc('get_orgs_v7') + await supabase.rpc('cli_check_permission' as any, { + apikey, + permission_key: permissionKey, + org_id: scope.orgId ?? null, + app_id: scope.appId ?? null, + channel_id: scope.channelId ?? null, + }) + ` + + expect(extractPublishedCliRpcCallsFromSource(source)).toEqual([ + { name: 'cli_check_permission', argKeys: ['apikey', 'app_id', 'channel_id', 'org_id', 'permission_key'] }, + { name: 'get_orgs_v7', argKeys: [] }, + { name: 'get_user_id', argKeys: ['apikey'] }, + ]) + }) + + it.concurrent('unions rpc arg keys from ternary object literals', () => { + const source = ` + await supabase + .rpc('has_usage_credits_org', appId ? { orgid: orgId, appid: appId } : { orgid: orgId }) + .single() + ` + + expect(extractPublishedCliRpcCallsFromSource(source)).toEqual([ + { name: 'has_usage_credits_org', argKeys: ['appid', 'orgid'] }, + ]) + }) + + it.concurrent('extracts multiline rpc args from chained supabase calls', () => { + const source = ` + const { data: bundleRows } = await withSupabaseSource('channels.currentBundleName', () => supabase + .rpc('get_channel_current_bundle_rbac' as any, { + p_app_id: appId, + p_channel_id: channelId, + })) + ` + + expect(extractPublishedCliRpcCallsFromSource(source)).toEqual([ + { name: 'get_channel_current_bundle_rbac', argKeys: ['p_app_id', 'p_channel_id'] }, + ]) + }) + + it.concurrent('marks dynamic rpc args as unresolved instead of zero-arg', () => { + const source = ` + await supabase.rpc('is_allowed_action_org_action', args) + ` + + expect(extractPublishedCliRpcCallsFromSource(source)).toEqual([ + { name: 'is_allowed_action_org_action', argKeys: null }, + ]) + }) + + it.concurrent('marks unknown rpc casts as unresolved instead of zero-arg', () => { + const source = ` + await supabase.rpc('get_user_id' as unknown as never, { apikey }) + ` + + expect(extractPublishedCliRpcCallsFromSource(source)).toEqual([ + { name: 'get_user_id', argKeys: null }, + ]) + }) + + it.concurrent('matches postgrest overloads from provided rpc argument keys', () => { + expect(rpcCallMatchesOverload( + { name: 'get_user_id', argKeys: ['apikey'] }, + ['apikey'], + 0, + 1, + )).toBe(true) + + expect(rpcCallMatchesOverload( + { name: 'get_user_id', argKeys: ['apikey', 'app_id'] }, + ['apikey', 'app_id'], + 0, + 2, + )).toBe(true) + + expect(rpcCallMatchesOverload( + { name: 'get_user_id', argKeys: ['apikey'] }, + ['apikey', 'app_id'], + 0, + 2, + )).toBe(false) + + expect(rpcCallMatchesOverload( + { name: 'get_orgs_v7', argKeys: [] }, + [], + 0, + 0, + )).toBe(true) + + expect(rpcCallMatchesOverload( + { name: 'get_channel_current_bundle_rbac', argKeys: [] }, + ['p_app_id', 'p_channel_id'], + 0, + 2, + )).toBe(false) + + expect(rpcCallMatchesOverload( + { name: 'get_orgs_v7', argKeys: ['org_id'] }, + ['org_id', 'is_invite'], + 1, + 2, + )).toBe(true) + + expect(rpcCallMatchesOverload( + { name: 'get_orgs_v7', argKeys: [] }, + ['org_id', 'is_invite'], + 1, + 2, + )).toBe(false) + + expect(rpcCallMatchesOverload( + { name: 'is_allowed_action_org_action', argKeys: null }, + [], + 0, + 0, + )).toBe(true) + }) +})