diff --git a/.env.example b/.env.example index d0f560b6..2bb15880 100644 --- a/.env.example +++ b/.env.example @@ -1,55 +1,53 @@ -# Root .env.local is the only local application credential file. Local Workers -# use the production Supabase database through its public session pooler and the -# same three least-privilege roles as production Hyperdrive. Keep each password -# identical to the password configured on only its matching production role. -# Administrative migration credentials belong in .env.migrate, never here. -SUPABASE_GATEWAY_DATABASE_URL=postgresql://app_gateway.snqtclnmhcaupqynjyux:replace_with_gateway_role_password@aws-0-ap-south-1.pooler.supabase.com:5432/postgres?sslmode=require&uselibpqcompat=true -SUPABASE_AGENT_DATABASE_URL=postgresql://app_agent.snqtclnmhcaupqynjyux:replace_with_agent_role_password@aws-0-ap-south-1.pooler.supabase.com:5432/postgres?sslmode=require&uselibpqcompat=true -SUPABASE_WEBHOOKS_DATABASE_URL=postgresql://app_webhooks.snqtclnmhcaupqynjyux:replace_with_webhooks_role_password@aws-0-ap-south-1.pooler.supabase.com:5432/postgres?sslmode=require&uselibpqcompat=true +# Run `pnpm dev:setup` to create the permission-restricted .env.local file. These +# neutral placeholders document the contract; do not hand-edit pooler URLs when +# the wizard can assemble and validate them for you. -# Clerk development instance. These test keys are for this laptop only; every -# Vercel environment uses the production Clerk instance. -NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_replace_me -CLERK_SECRET_KEY=sk_test_replace_me -# The seven optional Clerk/Composio/Daytona/Polar webhook, integration, and checkout keys may stay empty; affected local paths return 503. -CLERK_WEBHOOK_SIGNING_SECRET= +# Supabase session pooler only: *.pooler.supabase.com, port 5432, /postgres, +# and the role-qualified username .. A free dedicated +# Supabase project is sufficient. Administrative credentials stay in +# .env.migrate and never enter the application environment. +SUPABASE_GATEWAY_DATABASE_URL=postgresql://app_gateway.:@.pooler.supabase.com:5432/postgres?sslmode=require&uselibpqcompat=true +SUPABASE_AGENT_DATABASE_URL=postgresql://app_agent.:@.pooler.supabase.com:5432/postgres?sslmode=require&uselibpqcompat=true +SUPABASE_WEBHOOKS_DATABASE_URL=postgresql://app_webhooks.:@.pooler.supabase.com:5432/postgres?sslmode=require&uselibpqcompat=true -# Browser-visible local routing configuration; these values are not secrets. -# The real preview-proxy Worker is service-bound behind the -# gateway and serves each sandbox on *.localhost:8787. No second preview domain -# or cloud development deployment is required. +# Clerk development instance. Production keys are rejected locally. Configure +# the Clerk session token to expose metadata={{user.public_metadata}}. +NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_ +CLERK_SECRET_KEY=sk_test_ +CLERK_WEBHOOK_SIGNING_SECRET= NEXT_PUBLIC_GATEWAY_URL=http://127.0.0.1:8787 -# Daytona development access. -DAYTONA_API_KEY= +# Daytona development access. DAYTONA_SANDBOX_SNAPSHOT is an immutable snapshot +# built from infra/containers/sandbox. The webhook secret is required; unlike +# optional provider groups, it may not stay empty. +DAYTONA_API_KEY= DAYTONA_API_URL=https://app.daytona.io/api DAYTONA_PREVIEW_HOST_SUFFIXES=daytonaproxy01.net,proxy.daytona.work -# Required: set an explicit development snapshot so local startup can never -# inherit the production snapshot from the committed Worker configuration. -DAYTONA_SANDBOX_SNAPSHOT= +DAYTONA_SANDBOX_SNAPSHOT= DAYTONA_TARGET=us DAYTONA_WORKSPACE_VOLUME=cheatcode-workspaces-development -DAYTONA_WEBHOOK_SIGNING_SECRET= -PREVIEW_TOKEN_SECRET=replace_with_a_distinct_32_byte_secret -# Optional when the Daytona account requires an explicit organization. +DAYTONA_WEBHOOK_SIGNING_SECRET= DAYTONA_ORG_ID= -# Agent providers and integrations. +# Optional integrations. Skipping Composio disables connected apps; skipping +# DeepSeek means users rely on BYOK for that provider. COMPOSIO_API_KEY= COMPOSIO_AUTH_CONFIGS= COMPOSIO_WEBHOOK_SECRET= -# Optional platform fallback; users can rely on BYOK instead. DEEPSEEK_PLATFORM_API_KEY= -# Polar local development always uses the sandbox account. +# Optional Polar sandbox billing. Skipping this group disables local checkout +# and billing webhooks while core agent flows remain available. POLAR_ACCESS_TOKEN= POLAR_SERVER=sandbox POLAR_WEBHOOK_SECRET= POLAR_PRODUCT_ID_PRO= POLAR_PRODUCT_ID_PREMIUM= -# Internal local contracts. -DATABASE_CONTEXT_SIGNING_SECRET_AGENT=replace_with_a_distinct_32_byte_secret -DATABASE_CONTEXT_SIGNING_SECRET_GATEWAY=replace_with_a_distinct_32_byte_secret -DATABASE_CONTEXT_SIGNING_SECRET_WEBHOOKS=replace_with_a_distinct_32_byte_secret -OUTPUT_DOWNLOAD_SIGNING_SECRET=replace_with_a_distinct_32_byte_secret +# Generated by `pnpm dev:setup`; each value is at least 32 bytes. Values within the +# two documented distinctness groups are never reused. +DATABASE_CONTEXT_SIGNING_SECRET_AGENT= +DATABASE_CONTEXT_SIGNING_SECRET_GATEWAY= +DATABASE_CONTEXT_SIGNING_SECRET_WEBHOOKS= +PREVIEW_TOKEN_SECRET= +OUTPUT_DOWNLOAD_SIGNING_SECRET= diff --git a/.env.migrate.example b/.env.migrate.example index 4179b6aa..e0b1c67b 100644 --- a/.env.migrate.example +++ b/.env.migrate.example @@ -1,8 +1,8 @@ -# Copy to .env.migrate only on an authorized operator workstation. This file -# targets the production Supabase database and is never loaded by the app, -# Compose, Wrangler, Next.js, or any Worker. -SUPABASE_MIGRATION_URL=postgresql://postgres:replace_with_production_admin_password@db.snqtclnmhcaupqynjyux.supabase.co:5432/postgres?sslmode=require -SUPABASE_MIGRATION_EXPECTED_HOST=db.snqtclnmhcaupqynjyux.supabase.co +# Generated by `pnpm dev:setup` from a direct or session-pooler Supabase admin +# connection. Keep this file on an authorized workstation only; the app never +# loads it. Placeholders are intentionally project-neutral. +SUPABASE_MIGRATION_URL=postgresql://postgres:@db..supabase.co:5432/postgres?sslmode=require +SUPABASE_MIGRATION_EXPECTED_HOST=db..supabase.co SUPABASE_MIGRATION_EXPECTED_DATABASE=postgres SUPABASE_MIGRATION_EXPECTED_ROLE=postgres -SUPABASE_MIGRATION_EXPECTED_SYSTEM_IDENTIFIER=replace_with_pg_control_system_identifier +SUPABASE_MIGRATION_EXPECTED_SYSTEM_IDENTIFIER= diff --git a/.github/assets/cheatcode-home.png b/.github/assets/cheatcode-home.png new file mode 100644 index 00000000..e9754600 Binary files /dev/null and b/.github/assets/cheatcode-home.png differ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..7039d342 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,84 @@ +# Contributing to Cheatcode + +Thanks for helping improve Cheatcode. Read [AGENTS.md](AGENTS.md) before making +changes; it is the canonical architecture, security, and coding guide for +people and coding agents. + +## Set up the repository + +Use the exact Node and pnpm versions declared in `package.json`, then run: + +```bash +nvm install +nvm use +corepack enable +CI=true pnpm install +pnpm dev:setup +``` + +The guided wizard creates the ignored `.env.local` and `.env.migrate` files, +prepares a dedicated Supabase project, verifies all three runtime database +roles, and can start the Compose stack. A free Supabase project is sufficient. +You also need Clerk development keys and a Daytona API key plus an immutable +sandbox snapshot built from `infra/containers/sandbox`. + +Never commit either env file, real credentials, database dumps, or sanitized +copies of secrets. Never run a migration apply against a target you have not +positively identified and reviewed. Migration files are append-only. + +Run `pnpm dev:setup --check` for a read-only check of the host, environment, +migration ledger, database connectivity, and signed-context probes. + +## Make changes + +- Preserve package and application ownership boundaries. +- Read the relevant README before changing a public export, database contract, + deployment topology, sandbox boundary, or environment surface. +- Use pnpm and Turborepo; do not substitute npm or Yarn. +- Keep unrelated worktree changes intact. +- Follow the strict TypeScript and naming conventions in `AGENTS.md`. + +To change or rotate local role passwords, signing secrets, or provider values, +rerun `pnpm dev:setup` and replace the relevant prompted values. Existing values are +preserved by default, missing values are generated, and the idempotent migration, +Vault provisioning, and probe sequence resumes safely. There is intentionally no +separate secret-rotation command. + +## Verify changes + +Run the full final-tree gate chain before requesting review: + +```bash +pnpm lint +pnpm typecheck +pnpm turbo build --force +pnpm deadcode +pnpm architecture:check +pnpm turbo skills:build +``` + +For user-visible or integration behavior, also exercise the real flow with +`agent-browser --auto-connect --session cheatcode-debug`. Inspect screenshots, +the browser console, network activity, and application logs. Do not add a +parallel browser or product-flow test harness. + +Every pull request must include verification notes listing commands run, real +flows exercised, and any omitted check with its reason. + +## Commits and pull requests + +Use Conventional Commits, for example: + +```text +feat(agent): add a research tool +fix(db): preserve tenant context on retry +docs(setup): clarify Daytona snapshots +``` + +Keep commit subjects and human-authored pull request titles on one line and at +or below 72 characters. Explain why the change is needed, call out migration or +architecture effects, and include the verification notes above. + +By contributing repository-owned code, you agree that it is provided under the +root [LICENSE](LICENSE). Assets and third-party materials remain subject to +[NOTICE](NOTICE). diff --git a/README.md b/README.md index 9cc760de..c2db82fe 100644 --- a/README.md +++ b/README.md @@ -1,270 +1,155 @@ -# Cheatcode V2 - -Cheatcode is a TypeScript-first generalist AI agent platform with a Vercel-hosted Next.js frontend, Cloudflare Workers, Durable Objects, Workflows, Daytona Sandboxes, Supabase Postgres, Clerk, and Polar. - -The live source, package READMEs, migrations, and deployment configuration define the current system. The deleted `plan.md` is intentionally not authoritative and must not be restored. - -## Run Cheatcode locally - -`pnpm dev` is the only supported full-stack local entrypoint. It builds a -reproducible Docker image and starts: +# Cheatcode + +Cheatcode is a source-available generalist AI agent platform. Give it an +outcome—not a sequence of tool calls—and it can build applications, create +documents and media, research the live web, and operate browser workflows in an +isolated workspace. + +The project is built for people who want an inspectable, self-hostable agent +stack without surrendering provider choice. Users bring their own model keys; +the platform keeps long-running work, browser execution, files, generated +outputs, and tenant data behind explicit boundaries. + +![Cheatcode home screen](.github/assets/cheatcode-home.png) + +## Architecture + +```mermaid +flowchart LR + Browser[Next.js web app] --> Gateway[Cloudflare gateway Worker] + Gateway --> Agent[Agent Worker + Workflows] + Gateway --> Webhooks[Webhooks Worker] + Gateway --> Preview[Preview proxy] + Agent --> Sandbox[Daytona sandbox] + Gateway --> DB[(Supabase Postgres)] + Agent --> DB + Webhooks --> DB + Agent --> R2[(Cloudflare R2)] + Agent --> DO[Durable Objects] +``` -- the Next.js web app; -- the gateway, agent, webhooks, and preview-proxy Workers in one chained local - Wrangler process; -- local Durable Object, KV, R2, Workflow, and Wrangler state; and -- the shared package build watcher. +- Next.js 16 and React 19 provide the product UI. +- Cloudflare Workers, Workflows, and Durable Objects own admission, execution, + webhooks, and streaming. +- Daytona supplies one isolated workspace and browser runtime per project. +- Supabase Postgres stores metadata and durable workflow state through three + least-privilege roles. Generated files live in R2, not Postgres. +- Clerk provides authentication; Polar and Composio are optional local groups. -The application processes run locally, but a fully functional stack still uses -real remote services. In particular, local Workers connect to the production -Supabase database through its public session pooler and three isolated runtime -roles, and agents create development sandboxes in Daytona. Local startup never -starts Postgres, applies migrations, or deploys anything to Cloudflare or -Vercel. +Package and application READMEs document the detailed ownership boundaries. -### Prerequisites +## Quickstart -Install: +You need: -- Docker Desktop or Docker Engine with a recent Docker Compose release that - supports `docker compose up --watch`; -- NVM (or another version manager capable of selecting the exact Node version - in `.nvmrc`); and -- Corepack, which supplies the exact pnpm version declared in `package.json`. +- Node `24.18.0` and pnpm `11.15.0` (the exact versions in `package.json`); +- Docker with Docker Compose; +- a dedicated Supabase project—the free tier is sufficient; +- Clerk development keys; and +- a Daytona API key plus an immutable Cheatcode sandbox snapshot. -Prepare and verify the host toolchain: +Install and launch the guided setup: ```bash nvm install nvm use corepack enable -corepack prepare pnpm@11.15.0 --activate - -node --version -pnpm --version -docker compose version -docker info +CI=true pnpm install +pnpm dev:setup ``` -The expected Node and pnpm versions are `v24.18.0` and `11.15.0`. Do not ignore -an engine warning: select or install Node 24.18.0 before installing packages or -running repository commands. Docker must be running before `pnpm dev`. +The wizard checks the toolchain and local ports, collects masked credentials, +generates signing secrets and database-role passwords, writes `.env.local` and +`.env.migrate` atomically with mode `0600`, confirms the Supabase target, applies +the migration journal, provisions the runtime logins and Vault secrets, runs +end-to-end database probes, and can start the Compose stack. -### Configure local credentials +It deliberately expects a dedicated Supabase project. The wizard stays simple: +you supply the project ref, session-pooler host, and direct or session-pooler +admin connection string from the Supabase dashboard. Runtime URLs are assembled +for you and always use port `5432`; transaction pooling on `6543` is rejected. -Create the one local application environment file: +Verify an existing setup without changing files or database state: ```bash -cp .env.example .env.local -chmod 600 .env.local +pnpm dev:setup --check ``` -Fill every required value in `.env.local`. Keep the following boundaries: - -- `SUPABASE_GATEWAY_DATABASE_URL`, `SUPABASE_AGENT_DATABASE_URL`, and - `SUPABASE_WEBHOOKS_DATABASE_URL` are the production Supabase session-pooler - URLs for `app_gateway`, `app_agent`, and `app_webhooks`. Do not use a direct - database URL, an administrative role, `service_role`, or one role's password - for another role. -- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` and `CLERK_SECRET_KEY` must come from the - Clerk development instance and must begin with `pk_test_` and `sk_test_`. - Production Clerk keys are intentionally rejected locally. -- `DAYTONA_API_KEY`, `DAYTONA_SANDBOX_SNAPSHOT`, `DAYTONA_TARGET`, and - `DAYTONA_WORKSPACE_VOLUME` select the development Daytona environment. - `DAYTONA_WORKSPACE_VOLUME` must remain - `cheatcode-workspaces-development`; never point local runs at the production - workspace volume. -- `POLAR_SERVER` must remain `sandbox`. Add the Polar sandbox access token, - webhook secret, and sandbox product IDs to exercise billing locally. -- Each signing secret group in `.env.example` must contain non-placeholder - values of at least 32 UTF-8 bytes. Secrets within a group must be distinct. - The startup runner checks these requirements before launching a Worker. -- Keep `NEXT_PUBLIC_GATEWAY_URL=http://127.0.0.1:8787` for the standard local - topology. The web app derives `localhost` previews locally and the owned - `trycheatcode.com` preview apex in Vercel. Release identity is derived - automatically from Git in deployments and uses `development` locally. -- `COMPOSIO_API_KEY`, `COMPOSIO_AUTH_CONFIGS`, and - `COMPOSIO_WEBHOOK_SECRET` are required for connected-tool flows. - `COMPOSIO_AUTH_CONFIGS` is the JSON object that maps each supported toolkit - name to its Composio auth-config ID. -- `DEEPSEEK_PLATFORM_API_KEY` is optional because users may rely entirely on - BYOK. `DAYTONA_ORG_ID` and Clerk webhook verification are optional only when - the corresponding account or callback flow is not being exercised. - -Do not copy `.env.production` into `.env.local`. Do not put database migration -credentials in this file; authorized operators keep those only in the ignored -`.env.migrate` file. The full variable list and safe local defaults live in -[`.env.example`](./.env.example). - -### Start the stack - -From the repository root: +Local endpoints: -```bash -pnpm dev -``` +- Web: +- Gateway: +- Gateway liveness: +- Wrangler inspector: + +Use `127.0.0.1` consistently for gateway cookies; browsers treat it as a +different cookie site from `localhost`. + +## Self-hosting -The first run builds the pinned Node 24.18.0/pnpm 11.15.0 image, installs the -locked workspace dependencies inside it, builds shared packages, validates -`.env.local`, generates permission-restricted local Wrangler configs, and then -starts the watchers. Subsequent source edits are synchronized into the -container. Changes to package manifests or the lockfile trigger an image -rebuild. +Supabase is the only supported Postgres topology. Cheatcode does not start a +local database, use Supabase Storage or Realtime, or expose runtime Workers to +an administrative credential. Each Worker connects through the shared Supabase +session pooler with its own `app_gateway`, `app_agent`, or `app_webhooks` login. -Wait for the Compose service to report `healthy`. In another terminal: +Before setup, publish an immutable Daytona snapshot from +[`infra/containers/sandbox`](infra/containers/sandbox). The protected workflow +used by the hosted project is `.github/workflows/build-snapshot.yml`: ```bash -docker compose --env-file .env.local ps -docker compose --env-file .env.local logs -f app +gh workflow run build-snapshot.yml --ref main -f confirmation=BUILD_SNAPSHOT ``` -Expected local endpoints: - -- Web app: `http://localhost:3001` -- Gateway and chained Workers: `http://127.0.0.1:8787` -- Gateway liveness: `http://127.0.0.1:8787/health/live` -- Gateway release convergence: `http://127.0.0.1:8787/health/release` -- Wrangler inspector: `http://127.0.0.1:9239` +If you operate a fork, configure that workflow's required Daytona credentials, +or follow the container README to build the same sandbox image in your own +Daytona environment. Enter the resulting immutable snapshot name in +`DAYTONA_SANDBOX_SNAPSHOT`; setup will not inherit the hosted project's value. -The Compose health check uses cheap gateway liveness. Release verification uses -the convergence endpoint, which also proves that the service-bound agent and -webhooks Workers report the same SHA. +Production deployments use Vercel for `apps/web` and Cloudflare for the four +Workers. Review each app's `wrangler.jsonc`, `apps/web/vercel.json`, and the +deployment workflows before changing that topology. Runtime provider keys are +BYOK and must continue through `packages/byok`. -### Stop or reset the stack +## Development -Stop all local services cleanly: +After setup, start or stop the stack with: ```bash +pnpm dev pnpm dev:down ``` -The normal shutdown keeps the local Next and Wrangler cache volumes so the next -start is faster. If those generated caches become corrupt, remove only those -local volumes and rebuild: +The normal stop preserves the local Next and Wrangler caches. If either cache +is corrupt, remove only the two cache volumes—there is no local database volume: ```bash -docker compose --env-file .env.local down --volumes --remove-orphans +docker compose --env-file .env.local down --remove-orphans +docker volume rm cheatcode-local_app-next cheatcode-local_app-wrangler pnpm dev ``` -This does not delete production Supabase data or Daytona workspaces. Project and -account deletion must still go through the application so its durable cleanup -workflow can remove remote resources correctly. - -### Troubleshooting - -- **Node engine mismatch:** run `nvm install 24.18.0 && nvm use 24.18.0`, then - confirm `node --version` before retrying. -- **Docker cannot connect:** start Docker Desktop or the Docker daemon and - confirm `docker info` succeeds. -- **A required environment value is missing:** read the startup error, update - the named value in `.env.local`, and rerun `pnpm dev`. The runner also rejects - production Clerk keys, unsafe database targets, reused signing secrets, and - cloud-only credentials in the local file. -- **Port already in use:** release ports `3001`, `8787`, and `9239`; the - supported Compose topology binds all three to loopback. -- **A dependency changed but the image did not rebuild:** run - `docker compose --env-file .env.local build --no-cache app`, then - `pnpm dev`. -- **The UI loads but an external feature fails:** confirm the relevant remote - service credential is populated and active. Supabase, Clerk, Daytona, Polar, - Composio, and provider APIs are not emulated by Compose. -- **A provider webhook is being tested:** the provider must be configured to - reach the local webhooks Worker through a trusted public ingress, and its - signing secret must match `.env.local`. Loopback URLs cannot receive - internet-originated callbacks by themselves. - -### Verify the product - -Product QA is direct browser operation only: - -```bash -agent-browser --auto-connect --session cheatcode-debug open http://localhost:3001 -agent-browser --auto-connect --session cheatcode-debug snapshot -i -``` - -Use the snapshot-ref workflow directly, re-snapshot after DOM changes, capture -screenshots, inspect console and resource output, and review the running app -logs. Do not add product-flow test scripts, browser wrappers, prompt drivers, -temporary validators, or package aliases that simulate product QA. Typecheck, -lint, and build are code-health gates, not product acceptance tests. +Do not use `docker compose down --volumes`: a broad volume reset obscures which +state is disposable. Remote Supabase data and Daytona workspaces are never +deleted by these commands. -## Code checks +The complete verification chain is: ```bash -pnpm skills:build -pnpm typecheck pnpm lint -pnpm build -pnpm architecture:check +pnpm typecheck +pnpm turbo build --force pnpm deadcode +pnpm architecture:check +pnpm turbo skills:build ``` -## Database migrations - -`scripts/migrate.ts` owns migration planning and execution. The repository keeps -one current-schema baseline plus future forward migrations in the Drizzle -journal; it does not retain the pre-launch migration archive. - -```bash -pnpm db:migrate -- --dry-run -pnpm db:migrate -- --apply -``` - -The migration command loads `.env.migrate` on an authorized operator -workstation, validates the administrative connection target and pinned database -identity before applying changes, and accepts protected process environment -values in automation. Migration credentials are never loaded by the app or -bound to a Worker. The runner verifies the exact source journal and final -production contract through the same pinned administrative session. - -The three production Worker configs commit their dedicated Hyperdrive binding -IDs. Infrastructure changes update those reviewed configs directly; there is no -runtime or local helper that mutates production bindings. - -## Production deployment - -The required `static-checks` workflow classifies each change before allocating -the heavier runners. It runs dependency-aware lint, typecheck, build, -architecture, dead-code, workflow, and lockfile checks only for affected -surfaces and their workspace dependents. Root build configuration changes still -run the complete suite. - -Vercel's Git integration deploys `apps/web` from the repository. Its native -monorepo dependency graph skips builds when neither the web app nor one of its -declared workspace dependencies changed. Dispatch `Deploy Cloudflare` from -`main` when a reviewed backend release should move to production: - -```bash -gh workflow run deploy-cloudflare.yml --ref main -``` - -The job runs under the protected `Production` environment, rejects non-`main` -dispatches before checkout, and requires a successful `Static Checks` push run -for the exact release commit before installing dependencies or accessing -deployment credentials. - -The workflow builds the four Workers once, binds the reviewed commit SHA into -each deployment, and publishes agent, webhooks, preview proxy, then gateway. -Gateway goes last so public traffic sees the new backend only after its service -dependencies are available. The explicit workflow avoids a second, fallible -change-detection layer at deploy time; ordinary CI and Vercel remain -dependency-aware. - -Schema migrations, Worker deployment, and Vercel deployment are explicit -operations. Verify Worker health and the production web revision whenever a -release moves more than one surface. - -Publish a new immutable Daytona snapshot after changing -`infra/containers/sandbox/` by dispatching the protected workflow from `main`: - -```bash -gh workflow run build-snapshot.yml --ref main -f confirmation=BUILD_SNAPSHOT -``` +See [CONTRIBUTING.md](CONTRIBUTING.md) for workflow and verification-note +expectations. Report security issues through [SECURITY.md](SECURITY.md), not a +public issue. -Review the emitted immutable snapshot name and commit it in the agent Worker -configuration. Production Daytona credentials and snapshot publication remain -inside that workflow. +## License -The repository contains only the active V2 implementation. The legacy V1 source -tree was permanently removed on July 13, 2026 after explicit user authorization. +Repository-owned code is source-available under the +[PolyForm Noncommercial License 1.0.0](LICENSE). Commercial use is not granted. +The Cheatcode name, logos, specified first-party assets, and bundled third-party +materials are excluded or separately governed as described in [NOTICE](NOTICE). diff --git a/package.json b/package.json index f1650e7d..fbd0665e 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dev": "docker compose --env-file .env.local up --build --watch --remove-orphans", "dev:down": "docker compose --env-file .env.local down --remove-orphans", "dev:services": "tsx scripts/dev.ts", + "dev:setup": "tsx scripts/setup.ts", "lint": "biome check --error-on-warnings .", "prepare": "lefthook install", "skills:build": "turbo skills:build", @@ -33,6 +34,7 @@ }, "devDependencies": { "@biomejs/biome": "catalog:", + "@clack/prompts": "catalog:", "@commitlint/cli": "catalog:", "@commitlint/config-conventional": "catalog:", "@types/node": "catalog:", diff --git a/packages/db/README.md b/packages/db/README.md index 0c477887..3a779a6d 100644 --- a/packages/db/README.md +++ b/packages/db/README.md @@ -21,6 +21,11 @@ branded transaction context supplied by those helpers. Administrative migration credentials are never exported by this package or loaded by an application process. +Self-hosted and local environments use a dedicated Supabase project. The three +runtime URLs target that project's shared session pooler on port 5432 with +role-qualified usernames (`app_.`). There is no local +Postgres mode and runtime code never receives the Supabase admin connection. + ## Current schema The schema modules under `src/schema/` define: @@ -105,6 +110,21 @@ The repository keeps a single current-schema Drizzle baseline at Future schema changes append ordinary forward Drizzle migrations. The pre-launch historical migration archive is intentionally absent. +The baseline's checksum was intentionally revised on 2026-07-31 to remove +unrunnable `supabase_admin` default-ACL recreations (platform-managed state the +`postgres` migration role is denied on every Supabase project). Databases that +recorded the pre-revision baseline fail ledger verification with "Migration +ledger diverges from source at position 0." The one-time remedy, run once as +the migration admin against that database only: + +```sql +update drizzle.__drizzle_migrations + set hash = '' + where created_at = 1784981026716; +``` + +Compute the hash with `shasum -a 256 packages/db/drizzle/0000_current_schema.sql`. + ```bash pnpm --filter @cheatcode/db db:generate pnpm db:migrate -- --dry-run @@ -121,9 +141,17 @@ The migration runner: 6. validates the complete current table, column, constraint, index, function, RLS, grant, role, and data-integrity contract. -The laptop application environment contains only the three runtime-role URLs. -Administrative migration credentials stay in `.env.migrate` or a protected -operations environment. +The target-parity migration also installs the required Supabase extensions and +removes the `anon`, `authenticated`, and `service_role` access and default ACLs +from `public`. This schema-USAGE revoke is deliberate because Cheatcode does +not use the Supabase Data API. Supabase-managed Vault-schema grants remain +untouched. + +`pnpm dev:setup` supplies the role passwords and signed-context Vault rows that a +schema migration cannot safely embed, then proves real logins and signed tenant +context through each runtime URL. The laptop application environment contains +only those three runtime-role URLs. Administrative migration credentials stay +in `.env.migrate` or a protected operations environment. ## Code checks diff --git a/packages/db/drizzle/0000_current_schema.sql b/packages/db/drizzle/0000_current_schema.sql index 7aa5ddf4..b4438c71 100644 --- a/packages/db/drizzle/0000_current_schema.sql +++ b/packages/db/drizzle/0000_current_schema.sql @@ -2,6 +2,9 @@ -- PostgreSQL database dump -- +-- NOTE: The 0000 checksum was intentionally revised on 2026-07-31; its ledger +-- hash was updated in the same production operation. + -- Cheatcode runs only on Supabase Postgres. Bootstrap the external objects that -- are intentionally outside the public schema dump before restoring it. CREATE SCHEMA IF NOT EXISTS extensions; @@ -4463,10 +4466,13 @@ ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON SEQUENC -- Name: DEFAULT PRIVILEGES FOR SEQUENCES; Type: DEFAULT ACL; Schema: public; Owner: - -- -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON SEQUENCES TO service_role; +-- The pg_dump of production also recreated supabase_admin's default ACLs for +-- sequences, functions, and tables at this point. Those statements are removed: +-- they are Supabase-managed platform state that already exists on every +-- project, they only affect supabase_admin-created objects (this schema +-- creates none), and the migration role `postgres` is denied ALTER DEFAULT +-- PRIVILEGES FOR ROLE supabase_admin (SQLSTATE 42501) on every Supabase +-- project, old or new. -- @@ -4480,10 +4486,6 @@ ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON FUNCTIO -- Name: DEFAULT PRIVILEGES FOR FUNCTIONS; Type: DEFAULT ACL; Schema: public; Owner: - -- -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON FUNCTIONS TO service_role; -- @@ -4497,10 +4499,6 @@ ALTER DEFAULT PRIVILEGES FOR ROLE postgres IN SCHEMA public GRANT ALL ON TABLES -- Name: DEFAULT PRIVILEGES FOR TABLES; Type: DEFAULT ACL; Schema: public; Owner: - -- -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO postgres; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO anon; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO authenticated; -ALTER DEFAULT PRIVILEGES FOR ROLE supabase_admin IN SCHEMA public GRANT ALL ON TABLES TO service_role; -- diff --git a/packages/db/drizzle/0003_target_parity.sql b/packages/db/drizzle/0003_target_parity.sql new file mode 100644 index 00000000..0db7b61a --- /dev/null +++ b/packages/db/drizzle/0003_target_parity.sql @@ -0,0 +1,78 @@ +CREATE EXTENSION IF NOT EXISTS vector WITH SCHEMA extensions; +--> statement-breakpoint +CREATE EXTENSION IF NOT EXISTS pg_stat_statements WITH SCHEMA extensions; +--> statement-breakpoint +-- Cheatcode V2 does not use the Supabase Data API. Revoking public-schema +-- USAGE from its roles is a deliberate tightening from the baseline grant. +-- Supabase-managed grants on the vault schema are intentionally untouched. +DO $$ +DECLARE + data_api_role text; +BEGIN + FOREACH data_api_role IN ARRAY ARRAY['anon', 'authenticated', 'service_role'] + LOOP + IF pg_catalog.to_regrole(data_api_role) IS NOT NULL THEN + EXECUTE pg_catalog.format('REVOKE USAGE ON SCHEMA public FROM %I', data_api_role); + EXECUTE pg_catalog.format( + 'REVOKE ALL PRIVILEGES ON ALL TABLES IN SCHEMA public FROM %I', data_api_role + ); + EXECUTE pg_catalog.format( + 'REVOKE ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public FROM %I', data_api_role + ); + EXECUTE pg_catalog.format( + 'REVOKE ALL PRIVILEGES ON ALL FUNCTIONS IN SCHEMA public FROM %I', data_api_role + ); + END IF; + END LOOP; +END +$$; +--> statement-breakpoint +REVOKE EXECUTE ON ALL FUNCTIONS IN SCHEMA public FROM PUBLIC; +--> statement-breakpoint +DO $$ +DECLARE + creating_role text; + data_api_role text; +BEGIN + -- supabase_admin's default ACLs are Supabase-managed platform state that the + -- migration role cannot and must not alter; they only affect + -- supabase_admin-created objects, which this schema never creates. + FOREACH creating_role IN ARRAY ARRAY['postgres'] + LOOP + IF pg_catalog.to_regrole(creating_role) IS NOT NULL THEN + EXECUTE pg_catalog.format( + 'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM PUBLIC', + creating_role + ); + EXECUTE pg_catalog.format( + 'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA public REVOKE ALL ON TABLES FROM PUBLIC', + creating_role + ); + EXECUTE pg_catalog.format( + 'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA public REVOKE ALL ON SEQUENCES FROM PUBLIC', + creating_role + ); + FOREACH data_api_role IN ARRAY ARRAY['anon', 'authenticated', 'service_role'] + LOOP + IF pg_catalog.to_regrole(data_api_role) IS NOT NULL THEN + EXECUTE pg_catalog.format( + 'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA public REVOKE EXECUTE ON FUNCTIONS FROM %I', + creating_role, + data_api_role + ); + EXECUTE pg_catalog.format( + 'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA public REVOKE ALL ON TABLES FROM %I', + creating_role, + data_api_role + ); + EXECUTE pg_catalog.format( + 'ALTER DEFAULT PRIVILEGES FOR ROLE %I IN SCHEMA public REVOKE ALL ON SEQUENCES FROM %I', + creating_role, + data_api_role + ); + END IF; + END LOOP; + END IF; + END LOOP; +END +$$; diff --git a/packages/db/drizzle/meta/_journal.json b/packages/db/drizzle/meta/_journal.json index bfe0fa25..13b33dc3 100644 --- a/packages/db/drizzle/meta/_journal.json +++ b/packages/db/drizzle/meta/_journal.json @@ -22,6 +22,13 @@ "when": 1785315835302, "tag": "0002_famous_mindworm", "breakpoints": true + }, + { + "idx": 3, + "version": "7", + "when": 1785484800000, + "tag": "0003_target_parity", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/db/src/database-context-signer.ts b/packages/db/src/database-context-signer.ts new file mode 100644 index 00000000..1adb8657 --- /dev/null +++ b/packages/db/src/database-context-signer.ts @@ -0,0 +1,68 @@ +export type DatabaseContextAudience = "app_agent" | "app_gateway" | "app_webhooks"; + +interface RawSignedDatabaseContext { + issuedAt: string; + nonce: string; + signature: string; + userId: string; +} + +interface RawDatabaseContextSigner { + sign(userId: string): Promise; +} + +const CONTEXT_DOMAIN = "cheatcode-database-context-v1"; +const MINIMUM_SECRET_BYTES = 32; + +export function createRawDatabaseContextSigner(config: { + audience: DatabaseContextAudience; + loadSecret: () => Promise; +}): RawDatabaseContextSigner { + let keyPromise: ReturnType | undefined; + const key = () => { + keyPromise ??= importSigningKey(config.loadSecret); + return keyPromise; + }; + return { + async sign(userId) { + const issuedAt = String(Date.now()); + const nonce = crypto.randomUUID(); + const payload = contextPayload(config.audience, userId, issuedAt, nonce); + const signature = await crypto.subtle.sign( + "HMAC", + await key(), + new TextEncoder().encode(payload), + ); + return { issuedAt, nonce, signature: bytesToHex(signature), userId }; + }, + }; +} + +function contextPayload( + audience: DatabaseContextAudience, + userId: string, + issuedAt: string, + nonce: string, +): string { + return [CONTEXT_DOMAIN, audience, userId, issuedAt, nonce].join("\n"); +} + +async function importSigningKey( + loadSecret: () => Promise, +): ReturnType { + const secret = await loadSecret(); + if (!secret || new TextEncoder().encode(secret).byteLength < MINIMUM_SECRET_BYTES) { + throw new Error("Database context signing secret must contain at least 32 bytes"); + } + return crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { hash: "SHA-256", name: "HMAC" }, + false, + ["sign"], + ); +} + +function bytesToHex(value: ArrayBuffer): string { + return Array.from(new Uint8Array(value), (byte) => byte.toString(16).padStart(2, "0")).join(""); +} diff --git a/packages/db/src/database-context.ts b/packages/db/src/database-context.ts index 8c01553e..861873cc 100644 --- a/packages/db/src/database-context.ts +++ b/packages/db/src/database-context.ts @@ -1,10 +1,12 @@ import { resolveWorkerSecret, type WorkerSecret } from "@cheatcode/env"; import type { UserId } from "@cheatcode/types"; - -type DatabaseRuntimeAudience = "app_agent" | "app_gateway" | "app_webhooks"; +import { + createRawDatabaseContextSigner, + type DatabaseContextAudience, +} from "./database-context-signer"; export interface DatabaseContextConfig { - audience: DatabaseRuntimeAudience; + audience: DatabaseContextAudience; signingSecret: WorkerSecret; } @@ -19,55 +21,15 @@ interface DatabaseContextSigner { sign(userId: UserId): Promise; } -const CONTEXT_DOMAIN = "cheatcode-database-context-v1"; -const MINIMUM_SECRET_BYTES = 32; - export function createDatabaseContextSigner(config: DatabaseContextConfig): DatabaseContextSigner { - let keyPromise: ReturnType | undefined; - const key = () => { - keyPromise ??= importSigningKey(config.signingSecret); - return keyPromise; - }; + const signer = createRawDatabaseContextSigner({ + audience: config.audience, + loadSecret: () => resolveWorkerSecret(config.signingSecret), + }); return { async sign(userId) { - const issuedAt = String(Date.now()); - const nonce = crypto.randomUUID(); - const payload = contextPayload(config.audience, userId, issuedAt, nonce); - const signature = await crypto.subtle.sign( - "HMAC", - await key(), - new TextEncoder().encode(payload), - ); - return { issuedAt, nonce, signature: bytesToHex(signature), userId }; + const signed = await signer.sign(userId); + return { ...signed, userId }; }, }; } - -function contextPayload( - audience: DatabaseRuntimeAudience, - userId: UserId, - issuedAt: string, - nonce: string, -): string { - return [CONTEXT_DOMAIN, audience, userId, issuedAt, nonce].join("\n"); -} - -async function importSigningKey( - secretBinding: WorkerSecret, -): ReturnType { - const secret = await resolveWorkerSecret(secretBinding); - if (!secret || new TextEncoder().encode(secret).byteLength < MINIMUM_SECRET_BYTES) { - throw new Error("Database context signing secret must contain at least 32 bytes"); - } - return crypto.subtle.importKey( - "raw", - new TextEncoder().encode(secret), - { hash: "SHA-256", name: "HMAC" }, - false, - ["sign"], - ); -} - -function bytesToHex(value: ArrayBuffer): string { - return Array.from(new Uint8Array(value), (byte) => byte.toString(16).padStart(2, "0")).join(""); -} diff --git a/packages/env/README.md b/packages/env/README.md index 358d0441..5b2722a5 100644 --- a/packages/env/README.md +++ b/packages/env/README.md @@ -20,11 +20,13 @@ pnpm --filter @cheatcode/env typecheck ## Env -See root `.env.example` for the local application contract. Its database URLs -use the production Supabase session pooler with the three least-privilege runtime -roles. Administrative migration values live separately in git-ignored -`.env.migrate` (template: `.env.migrate.example`) or protected automation -environment variables and are never loaded by the app or copied into a Worker. +See root `.env.example` for the local application contract. `pnpm dev:setup` +assembles project-agnostic URLs for a dedicated Supabase project's public +session pooler on port 5432, using the three least-privilege runtime roles. +Direct endpoints and transaction pooling are rejected for runtime connections. +Administrative migration values live separately in git-ignored `.env.migrate` +(template: `.env.migrate.example`) or protected automation environment +variables and are never loaded by the app or copied into a Worker. Gateway, release-SHA, deployment-target, and Clerk publishable-key validation has one canonical implementation in `./web-config`. The framework config @@ -64,6 +66,11 @@ Database-backed Workers require exactly one role-specific tenant-context binding entry and matching Supabase Vault secret. The three values are distinct and at least 32 bytes; there is no shared or compatibility binding. +The root setup wizard and `scripts/dev.ts` share one scalar local-environment +contract for required keys, forbidden cloud credentials, development value +pins, secret distinctness, and Supabase pooler topology. `pnpm dev:setup --check` +uses that same surface without mutating files or database state. + Destructive Worker-to-Worker calls use named Cloudflare RPC entrypoints with static authenticated caller/capability properties. The gateway receives only the resource-deletion entrypoint and webhooks receives only the agent-lifecycle diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5c411500..0ccb85dc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ catalogs: '@biomejs/biome': specifier: 2.5.2 version: 2.5.2 + '@clack/prompts': + specifier: 1.7.0 + version: 1.7.0 '@clerk/backend': specifier: 3.11.6 version: 3.11.6 @@ -220,6 +223,9 @@ importers: '@biomejs/biome': specifier: 'catalog:' version: 2.5.2 + '@clack/prompts': + specifier: 'catalog:' + version: 1.7.0 '@commitlint/cli': specifier: 'catalog:' version: 21.2.1(@types/node@22.20.1)(conventional-commits-parser@7.1.0)(typescript@6.0.3) @@ -1110,6 +1116,14 @@ packages: '@chevrotain/types@11.1.2': resolution: {integrity: sha512-U+HFai5+zmJCkK86QsaJtoITlboZHBqrVketcO2ROv865xfCMSFpELQoz1GkX5GzME8pTa+3kbKrZHQtI0gdbw==} + '@clack/core@1.4.3': + resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==} + engines: {node: '>= 20.12.0'} + + '@clack/prompts@1.7.0': + resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==} + engines: {node: '>= 20.12.0'} + '@clerk/backend@3.11.6': resolution: {integrity: sha512-cHAmZnSbr/z4HzyP00d8C7W9SjRZpdmzrS3q9SSdt176q3kw7KK6T0MBtn684tCx5E9sQPdCUB3Hvcu71hlWOA==} engines: {node: '>=20.9.0'} @@ -4560,9 +4574,18 @@ packages: fast-stable-stringify@1.0.0: resolution: {integrity: sha512-wpYMUmFu5f00Sm0cj2pfivpmawLZ0NKdviQ4w9zJeR8JVtOpOxHmLaJuj0vxvGqMJQWyP/COUkF75/57OKyRag==} + fast-string-truncated-width@3.0.3: + resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==} + + fast-string-width@3.0.2: + resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==} + fast-uri@3.1.4: resolution: {integrity: sha512-8JnbkQ4juDyvYs4mgFGQqg4yCYtFDtUtmp2QIQq11ZZe5CFQ5wcqm1rqDgAh/QdMySuBnPzMUiJUNZG5N/AiQw==} + fast-wrap-ansi@0.2.2: + resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -6996,6 +7019,18 @@ snapshots: '@chevrotain/types@11.1.2': {} + '@clack/core@1.4.3': + dependencies: + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + + '@clack/prompts@1.7.0': + dependencies: + '@clack/core': 1.4.3 + fast-string-width: 3.0.2 + fast-wrap-ansi: 0.2.2 + sisteransi: 1.0.5 + '@clerk/backend@3.11.6(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': dependencies: '@clerk/shared': 4.25.4(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -10324,8 +10359,18 @@ snapshots: fast-stable-stringify@1.0.0: {} + fast-string-truncated-width@3.0.3: {} + + fast-string-width@3.0.2: + dependencies: + fast-string-truncated-width: 3.0.3 + fast-uri@3.1.4: {} + fast-wrap-ansi@0.2.2: + dependencies: + fast-string-width: 3.0.2 + fastq@1.20.1: dependencies: reusify: 1.1.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 5d3a61ab..3e03b841 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -84,6 +84,7 @@ catalog: # 2.5.3-2.5.4 panic in the type-aware module resolver (biomejs/biome#10885). # Keep the last non-panicking patch until the upstream fix is released. '@biomejs/biome': 2.5.2 + '@clack/prompts': 1.7.0 '@clerk/backend': 3.11.6 '@clerk/nextjs': 7.5.19 '@clerk/ui': 1.25.4 diff --git a/scripts/db-provision.ts b/scripts/db-provision.ts new file mode 100644 index 00000000..e5776dcb --- /dev/null +++ b/scripts/db-provision.ts @@ -0,0 +1,218 @@ +import { createRequire } from "node:module"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { createRawDatabaseContextSigner } from "../packages/db/src/database-context-signer"; +import { + assertPinnedDatabaseIdentity, + configureDatabaseOperationSession, + type DatabaseIdentityExpectation, +} from "./database-operation-safety"; +import { loadDrizzleMigrations, verifyDrizzleMigrationIntegrity } from "./migration-drizzle"; +import type { PgClient } from "./pg-client"; + +interface PgModule { + Client: new (config: { connectionString: string }) => PgClient; +} + +export interface AdminDatabaseIdentity { + database: string; + role: string; + systemIdentifier: string; +} + +export interface RuntimeDatabaseCredentials { + databaseUrl: string; + role: RuntimeRole; + signingSecret: string; +} + +export interface DatabaseProvisionInput { + adminDatabaseUrl: string; + runtimeCredentials: readonly RuntimeDatabaseCredentials[]; + rolePasswords: Readonly>; +} + +type RuntimeRole = "app_agent" | "app_gateway" | "app_webhooks"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const VAULT_SECRET_CONTRACT: Readonly> = + { + app_agent: { + description: "Cheatcode signed tenant context HMAC for app_agent", + name: "cheatcode-database-context-app-agent-v1", + }, + app_gateway: { + description: "Cheatcode signed tenant context HMAC for app_gateway", + name: "cheatcode-database-context-app-gateway-v1", + }, + app_webhooks: { + description: "Cheatcode signed tenant context HMAC for app_webhooks", + name: "cheatcode-database-context-app-webhooks-v1", + }, + }; + +export async function readAdminDatabaseIdentity( + databaseUrl: string, +): Promise { + return withClient(databaseUrl, "cheatcode-setup-identity", async (client) => { + const result = await client.query( + `select current_database() as database, + current_user as role, + (select system_identifier::text from pg_control_system()) as system_identifier`, + ); + const row = result.rows[0]; + return { + database: requiredString(row, "database", "database identity"), + role: requiredString(row, "role", "database identity"), + systemIdentifier: requiredString(row, "system_identifier", "database identity"), + }; + }); +} + +export async function provisionDatabase(input: DatabaseProvisionInput): Promise { + await withClient(input.adminDatabaseUrl, "cheatcode-setup-provision", async (client) => { + for (const credential of input.runtimeCredentials) { + await setRolePassword(client, credential.role, input.rolePasswords[credential.role]); + await upsertContextSecret(client, credential.role, credential.signingSecret); + } + }); +} + +export async function verifyDatabaseSetup( + credentials: readonly RuntimeDatabaseCredentials[], +): Promise { + await Promise.all(credentials.map(verifyRuntimeCredential)); +} + +export async function verifyMigrationLedger( + databaseUrl: string, + expectation: DatabaseIdentityExpectation, +): Promise { + await withClient(databaseUrl, "cheatcode-setup-check", async (client) => { + await assertPinnedDatabaseIdentity(client, expectation, "dry-run"); + const migrations = await loadDrizzleMigrations(); + const applied = await verifyDrizzleMigrationIntegrity(client, migrations); + if (applied.size !== migrations.length) { + throw new Error( + `Migration ledger has ${migrations.length - applied.size} pending migration(s); run pnpm dev:setup.`, + ); + } + }); +} + +async function setRolePassword( + client: PgClient, + role: RuntimeRole, + password: string, +): Promise { + const formatted = await client.query( + "select pg_catalog.format('ALTER ROLE %I WITH PASSWORD %L', $1, $2) as statement", + [role, password], + ); + const statement = requiredString(formatted.rows[0], "statement", `${role} password statement`); + await client.query(statement); +} + +async function upsertContextSecret( + client: PgClient, + role: RuntimeRole, + plaintext: string, +): Promise { + const contract = VAULT_SECRET_CONTRACT[role]; + const existing = await client.query("select id::text from vault.secrets where name = $1", [ + contract.name, + ]); + if (existing.rows.length > 1) { + throw new Error(`Vault contains duplicate ${contract.name} rows.`); + } + const id = optionalString(existing.rows[0], "id"); + if (id) { + await client.query("select vault.update_secret($1::uuid, $2, $3, $4)", [ + id, + plaintext, + contract.name, + contract.description, + ]); + return; + } + await client.query("select vault.create_secret($1, $2, $3)", [ + plaintext, + contract.name, + contract.description, + ]); +} + +async function verifyRuntimeCredential(input: RuntimeDatabaseCredentials): Promise { + await withClient(input.databaseUrl, "cheatcode-setup-probe", async (client) => { + const userId = crypto.randomUUID(); + const signer = createRawDatabaseContextSigner({ + audience: input.role, + loadSecret: async () => input.signingSecret, + }); + const context = await signer.sign(userId); + await client.query("begin"); + try { + await setSignedContext(client, context); + const result = await client.query("select public.current_app_user()::text as user_id"); + if (requiredString(result.rows[0], "user_id", `${input.role} signed context`) !== userId) { + throw new Error(`${input.role} signed-context probe returned the wrong user.`); + } + } finally { + await client.query("rollback"); + } + }); +} + +async function setSignedContext( + client: PgClient, + context: { issuedAt: string; nonce: string; signature: string; userId: string }, +): Promise { + await client.query( + `select set_config('app.user_id', $1, true), + set_config('app.context_issued_at', $2, true), + set_config('app.context_nonce', $3, true), + set_config('app.context_signature', $4, true)`, + [context.userId, context.issuedAt, context.nonce, context.signature], + ); +} + +async function withClient( + databaseUrl: string, + applicationName: string, + operation: (client: PgClient) => Promise, +): Promise { + const client = createClient(databaseUrl); + await client.connect(); + try { + await configureDatabaseOperationSession(client, { + applicationName, + statementTimeout: "2min", + }); + return await operation(client); + } finally { + await client.end(); + } +} + +function createClient(databaseUrl: string): PgClient { + const dbRequire = createRequire(join(ROOT, "packages/db/package.json")); + const { Client } = dbRequire("pg") as PgModule; + return new Client({ connectionString: databaseUrl }); +} + +function requiredString( + row: Record | undefined, + key: string, + label: string, +): string { + const value = optionalString(row, key); + if (!value) { + throw new Error(`Unable to read ${label}.`); + } + return value; +} + +function optionalString(row: Record | undefined, key: string): string | undefined { + const value = row?.[key]; + return typeof value === "string" ? value : undefined; +} diff --git a/scripts/dev-worker-config.ts b/scripts/dev-worker-config.ts index ab358dcb..ab134419 100644 --- a/scripts/dev-worker-config.ts +++ b/scripts/dev-worker-config.ts @@ -2,6 +2,10 @@ import { chmodSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node: import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { type ConfigRecord, isRecord, parseJsoncObject } from "./jsonc"; +import { + validateSupabaseRuntimeDatabaseUrls, + validateSupabaseSessionPoolerUrl, +} from "./local-env-contract"; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const GATEWAY_WORKER_DIR = join(ROOT, "apps/gateway-worker"); @@ -29,13 +33,6 @@ const PRODUCTION_DATABASE_URL_BINDINGS: Partial< }, }; -const PRODUCTION_SUPABASE_TARGET = { - database: "postgres", - hostname: "aws-0-ap-south-1.pooler.supabase.com", - port: "5432", - projectRef: "snqtclnmhcaupqynjyux", -} as const; - const LOCAL_WORKER_SECRET_BINDINGS: Record = { "wrangler.jsonc": [ "CLERK_SECRET_KEY", @@ -83,6 +80,7 @@ const LOCAL_WORKER_VAR_BINDINGS: Record = { }; export function localWorkerConfigs(webPort: string, values: Record): string[] { + validateSupabaseRuntimeDatabaseUrls(values); return WORKER_CONFIGS.map((config) => createLocalWorkerConfig(config, webPort, values)); } @@ -238,33 +236,7 @@ function productionDatabaseConnectionString( if (!raw) { throw new Error(`.env.local is missing ${expected.envKey}.`); } - let url: URL; - try { - url = new URL(raw); - } catch { - throw new Error(`${expected.envKey} must be a PostgreSQL connection URL.`); - } - const expectedUsername = `${expected.role}.${PRODUCTION_SUPABASE_TARGET.projectRef}`; - const hasExpectedTarget = - url.hostname === PRODUCTION_SUPABASE_TARGET.hostname && - url.port === PRODUCTION_SUPABASE_TARGET.port && - url.pathname === `/${PRODUCTION_SUPABASE_TARGET.database}`; - const hasRequiredTls = - url.searchParams.size === 2 && - url.searchParams.get("sslmode") === "require" && - url.searchParams.get("uselibpqcompat") === "true"; - if ( - (url.protocol !== "postgres:" && url.protocol !== "postgresql:") || - decodeURIComponent(url.username) !== expectedUsername || - !url.password || - !hasExpectedTarget || - !hasRequiredTls || - url.hash - ) { - throw new Error( - `${expected.envKey} must use ${expectedUsername} on the production Supabase session pooler with sslmode=require and uselibpqcompat=true.`, - ); - } + validateSupabaseSessionPoolerUrl(raw, expected.envKey, expected.role); return raw; } diff --git a/scripts/dev.ts b/scripts/dev.ts index 2ee6d503..a356b85a 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -3,6 +3,7 @@ import { readFileSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; import { localWorkerConfigs, removeLocalWorkerConfigs } from "./dev-worker-config"; +import { parseEnvFile, validateLocalEnvironment } from "./local-env-contract"; const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const LOCAL_ENV_FILE = join(ROOT, ".env.local"); @@ -47,56 +48,12 @@ interface CommandSpec { type BooleanOption = "dryRun" | "skipInitialBuild" | "webOnly" | "workersOnly"; -const REQUIRED_WORKER_ENV = [ - "CLERK_SECRET_KEY", - "DAYTONA_API_KEY", - "DAYTONA_API_URL", - "DAYTONA_SANDBOX_SNAPSHOT", - "DAYTONA_TARGET", - "DAYTONA_WORKSPACE_VOLUME", - "DAYTONA_WEBHOOK_SIGNING_SECRET", - "DATABASE_CONTEXT_SIGNING_SECRET_AGENT", - "DATABASE_CONTEXT_SIGNING_SECRET_GATEWAY", - "DATABASE_CONTEXT_SIGNING_SECRET_WEBHOOKS", - "PREVIEW_TOKEN_SECRET", - "SUPABASE_AGENT_DATABASE_URL", - "SUPABASE_GATEWAY_DATABASE_URL", - "SUPABASE_WEBHOOKS_DATABASE_URL", - "OUTPUT_DOWNLOAD_SIGNING_SECRET", -] as const; - -const DISTINCT_LOCAL_SECRET_GROUPS = [ - [ - "DATABASE_CONTEXT_SIGNING_SECRET_AGENT", - "DATABASE_CONTEXT_SIGNING_SECRET_GATEWAY", - "DATABASE_CONTEXT_SIGNING_SECRET_WEBHOOKS", - ], - ["PREVIEW_TOKEN_SECRET", "OUTPUT_DOWNLOAD_SIGNING_SECRET"], -] as const; - -const REQUIRED_WEB_ENV = [ - "CLERK_SECRET_KEY", - "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", - "NEXT_PUBLIC_GATEWAY_URL", -] as const; - const WEB_CHILD_ENV_KEYS = [ "CLERK_SECRET_KEY", "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", "NEXT_PUBLIC_GATEWAY_URL", ] as const; -const FORBIDDEN_LOCAL_ENV = [ - "ANTHROPIC_API_KEY", - "CLOUDFLARE_API_TOKEN", - "CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE", - "DATABASE_URL", - "GOOGLE_API_KEY", - "NEXT_PUBLIC_SUPABASE_ANON_KEY", - "NEXT_PUBLIC_SUPABASE_URL", - "VERCEL_TOKEN", -] as const; - const BOOLEAN_FLAGS: ReadonlyMap = new Map([ ["--dry-run", "dryRun"], ["--skip-initial-build", "skipInitialBuild"], @@ -140,105 +97,11 @@ function readOptionValue(argv: string[], index: number, flag: string): string { return value; } -function unquoteEnvValue(value: string): string { - const trimmed = value.trim(); - if (trimmed.length < 2) { - return trimmed; - } - const quote = trimmed[0]; - const last = trimmed.at(-1); - if ((quote !== '"' && quote !== "'") || quote !== last) { - return trimmed; - } - return trimmed.slice(1, -1); -} - function readEnvFileValues(filePath: string): Record { - const values: Record = {}; - let content: string; try { - content = readFileSync(filePath, "utf8").replace(/^\uFEFF/, ""); + return parseEnvFile(readFileSync(filePath, "utf8")); } catch { - throw new Error(`Missing ${relative(ROOT, filePath)}. Copy .env.example to .env.local.`); - } - - for (const rawLine of content.split(/\r?\n/)) { - const line = rawLine.trim(); - if (!line || line.startsWith("#")) { - continue; - } - const delimiterIndex = line.indexOf("="); - if (delimiterIndex === -1) { - continue; - } - const key = line.slice(0, delimiterIndex).trim(); - if (/^[A-Z0-9_]+$/.test(key)) { - values[key] = unquoteEnvValue(line.slice(delimiterIndex + 1)); - } - } - return values; -} - -function validateLocalClerkSecrets(values: Record): void { - const publishableKey = values["NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"]; - const secretKey = values["CLERK_SECRET_KEY"]; - if (publishableKey && !publishableKey.startsWith("pk_test_")) { - throw new Error(".env.local must use a Clerk pk_test_ publishable key."); - } - if (secretKey && !secretKey.startsWith("sk_test_")) { - throw new Error(".env.local must use a Clerk sk_test_ secret key."); - } -} - -function validateDistinctLocalSecretGroups(values: Record): void { - for (const names of DISTINCT_LOCAL_SECRET_GROUPS) { - const secrets = names.map((name) => values[name] ?? ""); - if (secrets.some((secret) => new TextEncoder().encode(secret).byteLength < 32)) { - throw new Error( - `Local HMAC secrets must contain at least 32 UTF-8 bytes: ${names.join(", ")}.`, - ); - } - if (new Set(secrets).size !== secrets.length) { - throw new Error(`Local HMAC secrets must be distinct: ${names.join(", ")}.`); - } - } -} - -export function missingLocalEnvValues( - values: Record, - required: readonly string[], -): string[] { - return required.filter((key) => !values[key]); -} - -function validateLocalEnv(values: Record, options: DevOptions): void { - const forbidden = FORBIDDEN_LOCAL_ENV.filter((key) => values[key]); - if (forbidden.length > 0) { - throw new Error(`Remove cloud-only or unused values from .env.local: ${forbidden.join(", ")}.`); - } - - validateLocalClerkSecrets(values); - const required = [ - ...(options.workersOnly ? [] : REQUIRED_WEB_ENV), - ...(options.webOnly ? [] : REQUIRED_WORKER_ENV), - ]; - const missing = missingLocalEnvValues(values, required); - if (missing.length > 0) { - throw new Error(`.env.local is missing required local values: ${missing.join(", ")}.`); - } - if (!options.webOnly) { - validateDistinctLocalSecretGroups(values); - } - if (!options.webOnly && values["POLAR_SERVER"] !== "sandbox") { - throw new Error(".env.local must set POLAR_SERVER=sandbox for local development."); - } - if ( - !options.webOnly && - values["DAYTONA_WORKSPACE_VOLUME"] !== "cheatcode-workspaces-development" - ) { - throw new Error( - ".env.local must set DAYTONA_WORKSPACE_VOLUME=cheatcode-workspaces-development.", - ); + throw new Error(`Missing ${relative(ROOT, filePath)}. Run pnpm dev:setup.`); } } @@ -473,7 +336,7 @@ function waitForChildren(children: ChildProcess[]): Promise { async function main(): Promise { const options = parseArgs(process.argv.slice(2)); const values = readEnvFileValues(LOCAL_ENV_FILE); - validateLocalEnv(values, options); + validateLocalEnvironment(values, options); try { const commands = commandsFor(options, values); if (!options.skipInitialBuild) { diff --git a/scripts/local-env-contract.ts b/scripts/local-env-contract.ts new file mode 100644 index 00000000..b7acfbe8 --- /dev/null +++ b/scripts/local-env-contract.ts @@ -0,0 +1,324 @@ +export const REQUIRED_WORKER_ENV = [ + "CLERK_SECRET_KEY", + "DAYTONA_API_KEY", + "DAYTONA_API_URL", + "DAYTONA_SANDBOX_SNAPSHOT", + "DAYTONA_TARGET", + "DAYTONA_WORKSPACE_VOLUME", + "DAYTONA_WEBHOOK_SIGNING_SECRET", + "DATABASE_CONTEXT_SIGNING_SECRET_AGENT", + "DATABASE_CONTEXT_SIGNING_SECRET_GATEWAY", + "DATABASE_CONTEXT_SIGNING_SECRET_WEBHOOKS", + "PREVIEW_TOKEN_SECRET", + "SUPABASE_AGENT_DATABASE_URL", + "SUPABASE_GATEWAY_DATABASE_URL", + "SUPABASE_WEBHOOKS_DATABASE_URL", + "OUTPUT_DOWNLOAD_SIGNING_SECRET", +] as const; + +export const REQUIRED_WEB_ENV = [ + "CLERK_SECRET_KEY", + "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", + "NEXT_PUBLIC_GATEWAY_URL", +] as const; + +type RequiredWorkerKey = (typeof REQUIRED_WORKER_ENV)[number]; +type RequiredWebKey = (typeof REQUIRED_WEB_ENV)[number]; +export type RequiredKey = RequiredWorkerKey | RequiredWebKey; + +const FORBIDDEN_LOCAL_ENV = [ + "ANTHROPIC_API_KEY", + "CLOUDFLARE_API_TOKEN", + "CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE", + "DATABASE_URL", + "GOOGLE_API_KEY", + "NEXT_PUBLIC_SUPABASE_ANON_KEY", + "NEXT_PUBLIC_SUPABASE_URL", + "VERCEL_TOKEN", +] as const; + +export const PINNED_LOCAL_ENV_VALUES = { + DAYTONA_WORKSPACE_VOLUME: "cheatcode-workspaces-development", + POLAR_SERVER: "sandbox", +} as const; + +const DISTINCT_LOCAL_SECRET_GROUPS = [ + [ + "DATABASE_CONTEXT_SIGNING_SECRET_AGENT", + "DATABASE_CONTEXT_SIGNING_SECRET_GATEWAY", + "DATABASE_CONTEXT_SIGNING_SECRET_WEBHOOKS", + ], + ["PREVIEW_TOKEN_SECRET", "OUTPUT_DOWNLOAD_SIGNING_SECRET"], +] as const satisfies readonly (readonly RequiredKey[])[]; + +export const OPTIONAL_LOCAL_ENV_KEYS = [ + "CLERK_WEBHOOK_SIGNING_SECRET", + "COMPOSIO_API_KEY", + "COMPOSIO_AUTH_CONFIGS", + "COMPOSIO_WEBHOOK_SECRET", + "DAYTONA_ORG_ID", + "DAYTONA_PREVIEW_HOST_SUFFIXES", + "DEEPSEEK_PLATFORM_API_KEY", + "POLAR_ACCESS_TOKEN", + "POLAR_PRODUCT_ID_PREMIUM", + "POLAR_PRODUCT_ID_PRO", + "POLAR_SERVER", + "POLAR_WEBHOOK_SECRET", +] as const; + +export interface LocalEnvSurface { + webOnly: boolean; + workersOnly: boolean; +} + +export interface SupabasePoolerTarget { + database: string; + hostname: string; + port: string; + projectRef: string; +} + +const RUNTIME_DATABASE_KEYS = [ + ["SUPABASE_GATEWAY_DATABASE_URL", "app_gateway"], + ["SUPABASE_AGENT_DATABASE_URL", "app_agent"], + ["SUPABASE_WEBHOOKS_DATABASE_URL", "app_webhooks"], +] as const satisfies readonly (readonly [RequiredWorkerKey, string])[]; + +const SUPABASE_PROJECT_REF_PATTERN = /^[a-z0-9]{20}$/u; +const SUPABASE_POOLER_HOST_PATTERN = + /^(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+pooler\.supabase\.com$/u; + +export function parseSupabaseProjectRef(value: string, label = "Supabase project ref"): string { + if (!SUPABASE_PROJECT_REF_PATTERN.test(value)) { + throw new Error(`${label} must be the 20-character lowercase project ref from Supabase.`); + } + return value; +} + +export function validateSupabasePoolerHost(value: string): string { + const hostname = value.toLowerCase(); + if (!SUPABASE_POOLER_HOST_PATTERN.test(hostname)) { + throw new Error("Pooler host must match .pooler.supabase.com."); + } + return hostname; +} + +export function validateSupabaseSessionPoolerUrl( + raw: string, + envKey: string, + expectedRole: string, +): SupabasePoolerTarget { + const url = parsePostgresUrl(raw, envKey); + const username = decodeUrlComponent(url.username, `${envKey} username`); + const separator = username.lastIndexOf("."); + const role = username.slice(0, separator); + const projectRef = parseSupabaseProjectRef( + username.slice(separator + 1), + `${envKey} project ref`, + ); + validatePoolerUrlShape(url, envKey, role, expectedRole); + return { + database: url.pathname.slice(1), + hostname: url.hostname, + port: url.port, + projectRef, + }; +} + +export function validateSupabaseRuntimeDatabaseUrls( + values: Record, +): SupabasePoolerTarget { + const targets = RUNTIME_DATABASE_KEYS.map(([envKey, role]) => { + const value = values[envKey]; + if (!value) { + throw new Error(`.env.local is missing ${envKey}.`); + } + return [envKey, validateSupabaseSessionPoolerUrl(value, envKey, role)] as const; + }); + const first = targets[0]?.[1]; + if (!first) { + throw new Error("Supabase runtime database URL contract is empty."); + } + for (const [envKey, target] of targets.slice(1)) { + if (!samePoolerTarget(first, target)) { + throw new Error( + `${envKey} must share the session-pooler host, port, database, and project ref used by all runtime database URLs.`, + ); + } + } + return first; +} + +export function validateRequiredLocalValue(key: RequiredKey, value: string): string | undefined { + if (!value) { + return `${key} is required.`; + } + if (key === "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY" && !value.startsWith("pk_test_")) { + return `${key} must use a Clerk pk_test_ publishable key.`; + } + if (key === "CLERK_SECRET_KEY" && !value.startsWith("sk_test_")) { + return `${key} must use a Clerk sk_test_ secret key.`; + } + return undefined; +} + +function missingLocalEnvValues( + values: Record, + required: readonly string[], +): string[] { + return required.filter((key) => !values[key]); +} + +export function validateLocalEnvironment( + values: Record, + surface: LocalEnvSurface, +): void { + validateForbiddenValues(values); + validateLocalClerkSecrets(values); + const required = [ + ...(surface.workersOnly ? [] : REQUIRED_WEB_ENV), + ...(surface.webOnly ? [] : REQUIRED_WORKER_ENV), + ]; + const missing = missingLocalEnvValues(values, required); + if (missing.length > 0) { + throw new Error(`.env.local is missing required local values: ${missing.join(", ")}.`); + } + validateRequiredValues(values, required); + if (!surface.webOnly) { + validateDistinctLocalSecretGroups(values); + validatePinnedValues(values); + validateSupabaseRuntimeDatabaseUrls(values); + } +} + +export function parseEnvFile(content: string): Record { + const values: Record = {}; + for (const rawLine of content.replace(/^\uFEFF/u, "").split(/\r?\n/u)) { + const line = rawLine.trim(); + const delimiterIndex = line.indexOf("="); + if (!line || line.startsWith("#") || delimiterIndex === -1) { + continue; + } + const key = line.slice(0, delimiterIndex).trim(); + if (/^[A-Z0-9_]+$/u.test(key)) { + values[key] = unquoteEnvValue(line.slice(delimiterIndex + 1)); + } + } + return values; +} + +function parsePostgresUrl(raw: string, envKey: string): URL { + try { + const url = new URL(raw); + if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") { + throw new Error("wrong protocol"); + } + return url; + } catch { + throw new Error(`${envKey} must be a PostgreSQL connection URL.`); + } +} + +function validatePoolerUrlShape( + url: URL, + envKey: string, + actualRole: string, + expectedRole: string, +): void { + const hasTlsParameters = + url.searchParams.size === 2 && + url.searchParams.get("sslmode") === "require" && + url.searchParams.get("uselibpqcompat") === "true"; + const isValid = + actualRole === expectedRole && + Boolean(url.password) && + SUPABASE_POOLER_HOST_PATTERN.test(url.hostname) && + url.port === "5432" && + url.pathname === "/postgres" && + hasTlsParameters && + !url.hash; + if (!isValid) { + throw new Error( + `${envKey} must use ${expectedRole}. on a Supabase session pooler (*.pooler.supabase.com:5432/postgres) with sslmode=require and uselibpqcompat=true.`, + ); + } +} + +function decodeUrlComponent(value: string, label: string): string { + try { + return decodeURIComponent(value); + } catch { + throw new Error(`${label} must be valid URL-encoded text.`); + } +} + +function samePoolerTarget(left: SupabasePoolerTarget, right: SupabasePoolerTarget): boolean { + return ( + left.hostname === right.hostname && + left.port === right.port && + left.database === right.database && + left.projectRef === right.projectRef + ); +} + +function validateForbiddenValues(values: Record): void { + const forbidden = FORBIDDEN_LOCAL_ENV.filter((key) => values[key]); + if (forbidden.length > 0) { + throw new Error(`Remove cloud-only or unused values from .env.local: ${forbidden.join(", ")}.`); + } +} + +function validateLocalClerkSecrets(values: Record): void { + const publishableKey = values["NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"]; + const secretKey = values["CLERK_SECRET_KEY"]; + if (publishableKey && !publishableKey.startsWith("pk_test_")) { + throw new Error(".env.local must use a Clerk pk_test_ publishable key."); + } + if (secretKey && !secretKey.startsWith("sk_test_")) { + throw new Error(".env.local must use a Clerk sk_test_ secret key."); + } +} + +function validateRequiredValues(values: Record, required: readonly RequiredKey[]) { + for (const key of required) { + const issue = validateRequiredLocalValue(key, values[key] ?? ""); + if (issue) { + throw new Error(issue); + } + } +} + +function validateDistinctLocalSecretGroups(values: Record): void { + for (const names of DISTINCT_LOCAL_SECRET_GROUPS) { + const secrets = names.map((name) => values[name] ?? ""); + if (secrets.some((secret) => new TextEncoder().encode(secret).byteLength < 32)) { + throw new Error( + `Local HMAC secrets must contain at least 32 UTF-8 bytes: ${names.join(", ")}.`, + ); + } + if (new Set(secrets).size !== secrets.length) { + throw new Error(`Local HMAC secrets must be distinct: ${names.join(", ")}.`); + } + } +} + +function validatePinnedValues(values: Record): void { + for (const [key, expected] of Object.entries(PINNED_LOCAL_ENV_VALUES)) { + if (values[key] !== expected) { + throw new Error(`.env.local must set ${key}=${expected} for local development.`); + } + } +} + +function unquoteEnvValue(value: string): string { + const trimmed = value.trim(); + if (trimmed.length < 2) { + return trimmed; + } + const quote = trimmed[0]; + const last = trimmed.at(-1); + if ((quote !== '"' && quote !== "'") || quote !== last) { + return trimmed; + } + return trimmed.slice(1, -1); +} diff --git a/scripts/setup-keys.ts b/scripts/setup-keys.ts new file mode 100644 index 00000000..458df395 --- /dev/null +++ b/scripts/setup-keys.ts @@ -0,0 +1,38 @@ +import type { RequiredKey } from "./local-env-contract"; + +export interface KeyMeta { + label: string; + secret: boolean; +} + +export const SETUP_KEY_META: Record = { + CLERK_SECRET_KEY: { label: "Clerk secret key", secret: true }, + DATABASE_CONTEXT_SIGNING_SECRET_AGENT: { + label: "Agent database-context signing secret", + secret: true, + }, + DATABASE_CONTEXT_SIGNING_SECRET_GATEWAY: { + label: "Gateway database-context signing secret", + secret: true, + }, + DATABASE_CONTEXT_SIGNING_SECRET_WEBHOOKS: { + label: "Webhooks database-context signing secret", + secret: true, + }, + DAYTONA_API_KEY: { label: "Daytona API key", secret: true }, + DAYTONA_API_URL: { label: "Daytona API URL", secret: false }, + DAYTONA_SANDBOX_SNAPSHOT: { label: "Daytona sandbox snapshot", secret: false }, + DAYTONA_TARGET: { label: "Daytona target", secret: false }, + DAYTONA_WEBHOOK_SIGNING_SECRET: { + label: "Daytona webhook signing secret", + secret: true, + }, + DAYTONA_WORKSPACE_VOLUME: { label: "Daytona workspace volume", secret: false }, + NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: { label: "Clerk publishable key", secret: false }, + NEXT_PUBLIC_GATEWAY_URL: { label: "Local gateway URL", secret: false }, + OUTPUT_DOWNLOAD_SIGNING_SECRET: { label: "Output-download signing secret", secret: true }, + PREVIEW_TOKEN_SECRET: { label: "Preview-token signing secret", secret: true }, + SUPABASE_AGENT_DATABASE_URL: { label: "Agent database URL", secret: true }, + SUPABASE_GATEWAY_DATABASE_URL: { label: "Gateway database URL", secret: true }, + SUPABASE_WEBHOOKS_DATABASE_URL: { label: "Webhooks database URL", secret: true }, +}; diff --git a/scripts/setup-prompts.ts b/scripts/setup-prompts.ts new file mode 100644 index 00000000..0957c388 --- /dev/null +++ b/scripts/setup-prompts.ts @@ -0,0 +1,450 @@ +import { cancel, confirm, isCancel, log, password, text } from "@clack/prompts"; +import { + PINNED_LOCAL_ENV_VALUES, + parseSupabaseProjectRef, + type RequiredKey, + validateRequiredLocalValue, + validateSupabasePoolerHost, +} from "./local-env-contract"; +import { SETUP_KEY_META } from "./setup-keys"; +import { + type AdminDatabaseTarget, + assertSafeEnvValue, + generateSecret, + parseAdminDatabaseUrl, +} from "./setup-support"; + +export interface CollectedSetupValues { + adminTarget: AdminDatabaseTarget; + localValues: Record; + rolePasswords: Readonly>; +} + +type RuntimeRole = "app_agent" | "app_gateway" | "app_webhooks"; + +const SIGNING_SECRET_KEYS = [ + "DATABASE_CONTEXT_SIGNING_SECRET_GATEWAY", + "DATABASE_CONTEXT_SIGNING_SECRET_AGENT", + "DATABASE_CONTEXT_SIGNING_SECRET_WEBHOOKS", + "PREVIEW_TOKEN_SECRET", + "OUTPUT_DOWNLOAD_SIGNING_SECRET", +] as const satisfies readonly RequiredKey[]; + +const ROLE_DATABASE_KEYS: Readonly< + Record< + RuntimeRole, + | "SUPABASE_AGENT_DATABASE_URL" + | "SUPABASE_GATEWAY_DATABASE_URL" + | "SUPABASE_WEBHOOKS_DATABASE_URL" + > +> = { + app_agent: "SUPABASE_AGENT_DATABASE_URL", + app_gateway: "SUPABASE_GATEWAY_DATABASE_URL", + app_webhooks: "SUPABASE_WEBHOOKS_DATABASE_URL", +}; + +export async function collectSetupValues( + existingLocal: Record, + existingMigrate: Record, +): Promise { + log.info( + "Use a dedicated Supabase project. Copy the project ref and Database connection values from https://supabase.com/dashboard.", + ); + const projectRef = await promptProjectRef(existingLocal); + const poolerHost = await promptPoolerHost(existingLocal); + const adminUrl = await promptAdminUrl(existingMigrate, projectRef); + const rolePasswords = await promptRolePasswords(existingLocal); + const localValues = await collectApplicationValues(existingLocal); + Object.assign(localValues, runtimeDatabaseUrls(projectRef, poolerHost, rolePasswords)); + return { + adminTarget: parseAdminDatabaseUrl(adminUrl, projectRef), + localValues, + rolePasswords, + }; +} + +export async function confirmUnknownKeyRemoval( + values: Record, + knownKeys: ReadonlySet, + fileName: string, +): Promise> { + const unknown = Object.keys(values) + .filter((key) => !knownKeys.has(key)) + .sort(); + if (unknown.length === 0) { + return { ...values }; + } + log.warn(`${fileName} contains unknown keys: ${unknown.join(", ")}.`); + const shouldRemove = await promptConfirm(`Remove these unknown keys from ${fileName}?`, false); + if (!shouldRemove) { + return { ...values }; + } + return Object.fromEntries(Object.entries(values).filter(([key]) => !unknown.includes(key))); +} + +async function collectApplicationValues( + existing: Record, +): Promise> { + const values = { ...existing }; + await collectClerkValues(values); + await collectDaytonaValues(values); + for (const key of SIGNING_SECRET_KEYS) { + values[key] = await promptGeneratedSecret(key, values[key]); + } + await collectOptionalGroups(values); + values["NEXT_PUBLIC_GATEWAY_URL"] = "http://127.0.0.1:8787"; + Object.assign(values, PINNED_LOCAL_ENV_VALUES); + return values; +} + +async function collectClerkValues(values: Record): Promise { + log.info( + "Use Clerk development keys. Ensure the session token exposes metadata={{user.public_metadata}} or onboarding state will not reach the app.", + ); + values["NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"] = await promptRequiredText( + "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", + values["NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY"], + ); + values["CLERK_SECRET_KEY"] = await promptRequiredSecret( + "CLERK_SECRET_KEY", + values["CLERK_SECRET_KEY"], + ); + values["CLERK_WEBHOOK_SIGNING_SECRET"] = await promptOptionalSecret( + "Clerk webhook signing secret (optional; skipping disables local Clerk webhooks)", + values["CLERK_WEBHOOK_SIGNING_SECRET"], + ); +} + +async function collectDaytonaValues(values: Record): Promise { + log.info( + "Daytona requires an API key and immutable sandbox snapshot. Self-hosters build infra/containers/sandbox with the build-snapshot workflow first.", + ); + values["DAYTONA_API_KEY"] = await promptRequiredSecret( + "DAYTONA_API_KEY", + values["DAYTONA_API_KEY"], + ); + values["DAYTONA_API_URL"] = await promptRequiredText( + "DAYTONA_API_URL", + values["DAYTONA_API_URL"] ?? "https://app.daytona.io/api", + ); + values["DAYTONA_SANDBOX_SNAPSHOT"] = await promptRequiredText( + "DAYTONA_SANDBOX_SNAPSHOT", + values["DAYTONA_SANDBOX_SNAPSHOT"], + ); + values["DAYTONA_TARGET"] = await promptRequiredText( + "DAYTONA_TARGET", + values["DAYTONA_TARGET"] ?? "us", + ); + values["DAYTONA_WEBHOOK_SIGNING_SECRET"] = await promptRequiredSecret( + "DAYTONA_WEBHOOK_SIGNING_SECRET", + values["DAYTONA_WEBHOOK_SIGNING_SECRET"], + ); + values["DAYTONA_ORG_ID"] = await promptOptionalText( + "Daytona organization ID (optional)", + values["DAYTONA_ORG_ID"], + ); + values["DAYTONA_PREVIEW_HOST_SUFFIXES"] = + values["DAYTONA_PREVIEW_HOST_SUFFIXES"] ?? "daytonaproxy01.net,proxy.daytona.work"; +} + +async function collectOptionalGroups(values: Record): Promise { + await collectPolarValues(values); + await collectComposioValues(values); + const useDeepSeek = await promptConfirm( + "Configure a DeepSeek platform fallback? Skipping means users must use BYOK for DeepSeek.", + Boolean(values["DEEPSEEK_PLATFORM_API_KEY"]), + ); + values["DEEPSEEK_PLATFORM_API_KEY"] = useDeepSeek + ? await promptOptionalSecret("DeepSeek platform API key", values["DEEPSEEK_PLATFORM_API_KEY"]) + : ""; +} + +async function collectPolarValues(values: Record): Promise { + const usePolar = await promptConfirm( + "Configure Polar sandbox billing? Skipping disables local checkout and billing webhooks.", + Boolean(values["POLAR_ACCESS_TOKEN"]), + ); + if (!usePolar) { + clearValues(values, [ + "POLAR_ACCESS_TOKEN", + "POLAR_WEBHOOK_SECRET", + "POLAR_PRODUCT_ID_PRO", + "POLAR_PRODUCT_ID_PREMIUM", + ]); + return; + } + values["POLAR_ACCESS_TOKEN"] = await promptOptionalSecret( + "Polar sandbox access token", + values["POLAR_ACCESS_TOKEN"], + ); + values["POLAR_WEBHOOK_SECRET"] = await promptOptionalSecret( + "Polar sandbox webhook secret", + values["POLAR_WEBHOOK_SECRET"], + ); + values["POLAR_PRODUCT_ID_PRO"] = await promptOptionalText( + "Polar Pro product ID", + values["POLAR_PRODUCT_ID_PRO"], + ); + values["POLAR_PRODUCT_ID_PREMIUM"] = await promptOptionalText( + "Polar Premium product ID", + values["POLAR_PRODUCT_ID_PREMIUM"], + ); +} + +async function collectComposioValues(values: Record): Promise { + const useComposio = await promptConfirm( + "Configure Composio? Skipping disables connected-app authorization and tools.", + Boolean(values["COMPOSIO_API_KEY"]), + ); + if (!useComposio) { + clearValues(values, ["COMPOSIO_API_KEY", "COMPOSIO_AUTH_CONFIGS", "COMPOSIO_WEBHOOK_SECRET"]); + return; + } + values["COMPOSIO_API_KEY"] = await promptOptionalSecret( + "Composio API key", + values["COMPOSIO_API_KEY"], + ); + values["COMPOSIO_AUTH_CONFIGS"] = await promptOptionalSecret( + "Composio auth-config JSON", + values["COMPOSIO_AUTH_CONFIGS"], + ); + values["COMPOSIO_WEBHOOK_SECRET"] = await promptOptionalSecret( + "Composio webhook secret", + values["COMPOSIO_WEBHOOK_SECRET"], + ); +} + +async function promptProjectRef(existing: Record): Promise { + return promptTextValue("Supabase project ref", inferProjectRef(existing), (value) => { + try { + parseSupabaseProjectRef(value); + return undefined; + } catch (error) { + return errorMessage(error); + } + }); +} + +async function promptPoolerHost(existing: Record): Promise { + const value = await promptTextValue( + "Supabase session-pooler host", + inferPoolerHost(existing), + (candidate) => { + try { + validateSupabasePoolerHost(candidate); + return undefined; + } catch (error) { + return errorMessage(error); + } + }, + ); + return validateSupabasePoolerHost(value); +} + +async function promptAdminUrl( + existing: Record, + projectRef: string, +): Promise { + const current = existing["SUPABASE_MIGRATION_URL"]; + const result = await password({ + message: current + ? "Supabase admin connection string (Enter keeps existing; direct/session pooler only)" + : "Supabase admin connection string (direct/session pooler only)", + validate: (value) => { + const candidate = value || current || ""; + try { + assertSafeEnvValue("SUPABASE_MIGRATION_URL", candidate); + parseAdminDatabaseUrl(candidate, projectRef); + return undefined; + } catch (error) { + return errorMessage(error); + } + }, + }); + return unwrapPrompt(result) || current || ""; +} + +async function promptRolePasswords( + existing: Record, +): Promise>> { + return { + app_agent: await promptGeneratedPassword( + "app_agent", + existingRolePassword(existing, "app_agent"), + ), + app_gateway: await promptGeneratedPassword( + "app_gateway", + existingRolePassword(existing, "app_gateway"), + ), + app_webhooks: await promptGeneratedPassword( + "app_webhooks", + existingRolePassword(existing, "app_webhooks"), + ), + }; +} + +async function promptGeneratedSecret(key: RequiredKey, existing?: string): Promise { + const result = await password({ + message: `${SETUP_KEY_META[key].label} (Enter ${existing ? "keeps existing" : "generates one"})`, + validate: (value) => validateGeneratedSecretInput(key, value ?? "", existing), + }); + const value = unwrapPrompt(result); + return value || existing || generateSecret(); +} + +async function promptGeneratedPassword(role: RuntimeRole, existing?: string): Promise { + const result = await password({ + message: `${role} password (Enter ${existing ? "keeps existing" : "generates one"})`, + validate: (value) => validateSecretInput(`${role} password`, value ?? "", existing, 16), + }); + const value = unwrapPrompt(result); + return value || existing || generateSecret(); +} + +async function promptRequiredText(key: RequiredKey, existing?: string): Promise { + return promptTextValue(SETUP_KEY_META[key].label, existing, (value) => { + const unsafe = safeValueIssue(key, value); + return unsafe ?? validateRequiredLocalValue(key, value); + }); +} + +async function promptRequiredSecret(key: RequiredKey, existing?: string): Promise { + const result = await password({ + message: `${SETUP_KEY_META[key].label}${existing ? " (Enter keeps existing)" : ""}`, + validate: (value) => { + const candidate = value || existing || ""; + return safeValueIssue(key, candidate) ?? validateRequiredLocalValue(key, candidate); + }, + }); + return unwrapPrompt(result) || existing || ""; +} + +async function promptOptionalText(message: string, existing?: string): Promise { + return promptTextValue(message, existing, (value) => safeValueIssue(message, value)); +} + +async function promptOptionalSecret(message: string, existing?: string): Promise { + const result = await password({ + message: `${message}${existing ? " (Enter keeps existing)" : ""}`, + validate: (value) => safeValueIssue(message, value || existing || ""), + }); + return unwrapPrompt(result) || existing || ""; +} + +async function promptTextValue( + message: string, + existing: string | undefined, + validate: (value: string) => string | undefined, +): Promise { + const result = await text({ + message, + ...(existing ? { initialValue: existing } : {}), + validate: (value) => validate(value ?? ""), + }); + return unwrapPrompt(result); +} + +async function promptConfirm(message: string, initialValue: boolean): Promise { + return unwrapPrompt(await confirm({ initialValue, message })); +} + +function runtimeDatabaseUrls( + projectRef: string, + poolerHost: string, + passwords: Readonly>, +): Record { + return Object.fromEntries( + (Object.entries(ROLE_DATABASE_KEYS) as Array<[RuntimeRole, string]>).map(([role, key]) => [ + key, + `postgresql://${role}.${projectRef}:${encodeURIComponent(passwords[role])}@${poolerHost}:5432/postgres?sslmode=require&uselibpqcompat=true`, + ]), + ); +} + +function inferProjectRef(values: Record): string | undefined { + const username = databaseUrlPart(values["SUPABASE_GATEWAY_DATABASE_URL"], "username"); + if (!username) { + return undefined; + } + const separator = username.lastIndexOf("."); + return separator === -1 ? undefined : username.slice(separator + 1); +} + +function inferPoolerHost(values: Record): string | undefined { + return databaseUrlPart(values["SUPABASE_GATEWAY_DATABASE_URL"], "hostname"); +} + +function existingRolePassword( + values: Record, + role: RuntimeRole, +): string | undefined { + return databaseUrlPart(values[ROLE_DATABASE_KEYS[role]], "password"); +} + +function databaseUrlPart( + raw: string | undefined, + part: "hostname" | "password" | "username", +): string | undefined { + if (!raw) { + return undefined; + } + try { + return decodeURIComponent(new URL(raw)[part]); + } catch { + return undefined; + } +} + +function validateGeneratedSecretInput( + key: string, + value: string, + existing: string | undefined, +): string | undefined { + return validateSecretInput(key, value, existing, 32); +} + +function validateSecretInput( + key: string, + value: string, + existing: string | undefined, + minimumBytes: number, +): string | undefined { + const candidate = value || existing; + if (!candidate) { + return undefined; + } + const unsafe = safeValueIssue(key, candidate); + if (unsafe) { + return unsafe; + } + return new TextEncoder().encode(candidate).byteLength < minimumBytes + ? `${key} must contain at least ${minimumBytes} UTF-8 bytes.` + : undefined; +} + +function safeValueIssue(key: string, value: string): string | undefined { + try { + assertSafeEnvValue(key, value); + return undefined; + } catch (error) { + return errorMessage(error); + } +} + +function unwrapPrompt(value: T | symbol): T { + if (isCancel(value)) { + cancel("Setup cancelled; no further steps were run."); + process.exit(1); + } + return value as T; +} + +function clearValues(values: Record, keys: readonly string[]): void { + for (const key of keys) { + values[key] = ""; + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "Invalid value."; +} diff --git a/scripts/setup-support.ts b/scripts/setup-support.ts new file mode 100644 index 00000000..ee00224e --- /dev/null +++ b/scripts/setup-support.ts @@ -0,0 +1,376 @@ +import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { chmod, readFile, rename, rm, writeFile } from "node:fs/promises"; +import { createServer } from "node:net"; +import { join } from "node:path"; +import { + parseEnvFile, + parseSupabaseProjectRef, + validateSupabasePoolerHost, +} from "./local-env-contract"; + +export interface AdminDatabaseTarget { + database: string; + hostname: string; + projectRef: string; + role: string; + url: string; +} + +export interface MigrationEnvironment { + SUPABASE_MIGRATION_EXPECTED_DATABASE: string; + SUPABASE_MIGRATION_EXPECTED_HOST: string; + SUPABASE_MIGRATION_EXPECTED_ROLE: string; + SUPABASE_MIGRATION_EXPECTED_SYSTEM_IDENTIFIER: string; + SUPABASE_MIGRATION_URL: string; +} + +interface PackageContract { + engines: { node: string }; + packageManager: string; +} + +interface CommandResult { + code: number; + stderr: string; + stdout: string; +} + +const LOCAL_PORTS = [3001, 8787, 9239] as const; + +export async function runSetupPreflight(root: string): Promise { + const contract = await readPackageContract(root); + if (process.version !== `v${contract.engines.node}`) { + throw new Error( + `Node preflight failed: expected v${contract.engines.node}, got ${process.version}.`, + ); + } + const expectedPnpm = contract.packageManager.replace(/^pnpm@/u, ""); + await assertCommandVersion("pnpm", ["--version"], expectedPnpm, "pnpm"); + await assertSuccessfulCommand("docker", ["compose", "version"], "Docker Compose"); + await assertSuccessfulCommand("docker", ["info"], "Docker daemon"); + const isStackRunning = await existingStackIsRunning(root); + if (!isStackRunning) { + await Promise.all(LOCAL_PORTS.map(assertPortAvailable)); + } +} + +export function parseAdminDatabaseUrl( + raw: string, + expectedProjectRef: string, +): AdminDatabaseTarget { + const url = parseDatabaseUrl(raw, "Supabase admin connection string"); + const username = decodeComponent(url.username, "Supabase admin username"); + const directRef = directAdminProjectRef(url.hostname); + const poolerRef = poolerAdminProjectRef(url.hostname, username); + const isDirect = directRef === expectedProjectRef && username === "postgres"; + const isPooler = poolerRef === expectedProjectRef; + const isValid = + Boolean(url.password) && + url.port === "5432" && + url.pathname === "/postgres" && + url.searchParams.get("sslmode") === "require" && + !url.hash && + (isDirect || isPooler); + if (!isValid) { + throw new Error( + "Admin connection must use postgres on the matching Supabase direct or session-pooler endpoint at port 5432 with sslmode=require.", + ); + } + return { + database: "postgres", + hostname: url.hostname, + projectRef: expectedProjectRef, + role: "postgres", + url: raw, + }; +} + +export function migrationEnvironment( + target: AdminDatabaseTarget, + systemIdentifier: string, +): MigrationEnvironment { + return { + SUPABASE_MIGRATION_EXPECTED_DATABASE: target.database, + SUPABASE_MIGRATION_EXPECTED_HOST: target.hostname, + SUPABASE_MIGRATION_EXPECTED_ROLE: target.role, + SUPABASE_MIGRATION_EXPECTED_SYSTEM_IDENTIFIER: systemIdentifier, + SUPABASE_MIGRATION_URL: target.url, + }; +} + +export function sanitizedMigrationChildEnvironment( + values: MigrationEnvironment, +): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = {}; + for (const [key, value] of Object.entries(process.env)) { + if (!key.startsWith("SUPABASE_MIGRATION_") && value !== undefined) { + env[key] = value; + } + } + return { ...env, ...values }; +} + +export async function runInheritedCommand( + command: string, + args: readonly string[], + root: string, + env: NodeJS.ProcessEnv = process.env, +): Promise { + await new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { cwd: root, env, stdio: "inherit" }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) { + resolvePromise(); + return; + } + reject(new Error(`${command} ${args.join(" ")} exited with code ${code ?? "unknown"}.`)); + }); + }); +} + +export async function readOptionalEnvFile(filePath: string): Promise> { + try { + return parseEnvFile(await readFile(filePath, "utf8")); + } catch (error) { + if (errorCode(error) === "ENOENT") { + return {}; + } + throw error; + } +} + +export async function writeEnvFileAtomic( + filePath: string, + values: Record, + order: readonly string[], + header: readonly string[], +): Promise { + const content = serializeEnv(values, order, header); + const temporaryPath = `${filePath}.tmp-${process.pid}-${randomBytes(6).toString("hex")}`; + try { + await writeFile(temporaryPath, content, { flag: "wx", mode: 0o600 }); + await chmod(temporaryPath, 0o600); + await rename(temporaryPath, filePath); + await chmod(filePath, 0o600); + } catch (error) { + await rm(temporaryPath, { force: true }); + throw error; + } +} + +export function generateSecret(bytes = 32): string { + return randomBytes(bytes).toString("base64url"); +} + +export function assertSafeEnvValue(key: string, value: string): void { + if ( + [...value].some((character) => { + const codePoint = character.codePointAt(0) ?? 0; + return codePoint <= 31 || codePoint === 127; + }) + ) { + throw new Error(`${key} contains a control character; paste a single-line value.`); + } + // Values are written bare so the same line round-trips identically through + // this wizard's reader, wrangler --env-file, and docker compose --env-file + // (compose treats quotes as literal characters). Shapes a bare line cannot + // represent are rejected instead of quoted. + if (value !== value.trim()) { + throw new Error(`${key} has leading or trailing whitespace; remove it.`); + } + if (value.startsWith('"') || value.startsWith("'")) { + throw new Error(`${key} must not start with a quote character.`); + } + if (value.includes("#")) { + throw new Error( + `${key} contains '#', which dotenv parsers read as a comment start; this value cannot be represented.`, + ); + } + if (value.includes("$")) { + throw new Error( + `${key} contains '$', which wrangler's env-file expansion substitutes with host variables; this value cannot be represented.`, + ); + } +} + +export async function pollLocalReadiness(timeoutMs = 120_000): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (await localEndpointsAreReady()) { + return; + } + await new Promise((resolvePromise) => setTimeout(resolvePromise, 1_000)); + } + throw new Error("Local stack readiness timed out after 120 seconds."); +} + +function serializeEnv( + values: Record, + order: readonly string[], + header: readonly string[], +): string { + for (const [key, value] of Object.entries(values)) { + assertSafeEnvValue(key, value); + } + const known = new Set(order); + const keys = [...order.filter((key) => values[key] !== undefined)]; + keys.push( + ...Object.keys(values) + .filter((key) => !known.has(key)) + .sort(), + ); + return `${header.map((line) => `# ${line}`).join("\n")}\n${keys + .map((key) => `${key}=${values[key] ?? ""}`) + .join("\n")}\n`; +} + +async function readPackageContract(root: string): Promise { + const parsed: unknown = JSON.parse(await readFile(join(root, "package.json"), "utf8")); + if (!isRecord(parsed) || !isRecord(parsed["engines"])) { + throw new Error("Toolchain preflight could not read package.json engines."); + } + const node = parsed["engines"]["node"]; + const packageManager = parsed["packageManager"]; + if (typeof node !== "string" || typeof packageManager !== "string") { + throw new Error("Toolchain preflight found an invalid package.json contract."); + } + return { engines: { node }, packageManager }; +} + +async function assertCommandVersion( + command: string, + args: readonly string[], + expected: string, + label: string, +): Promise { + const result = await captureCommand(command, args); + const actual = result.stdout.trim(); + if (result.code !== 0 || actual !== expected) { + throw new Error( + `${label} preflight failed: expected ${expected}, got ${actual || "unavailable"}.`, + ); + } +} + +async function assertSuccessfulCommand( + command: string, + args: readonly string[], + label: string, +): Promise { + const result = await captureCommand(command, args); + if (result.code !== 0) { + throw new Error(`${label} preflight failed: ${result.stderr.trim() || "command failed"}.`); + } +} + +function captureCommand(command: string, args: readonly string[]): Promise { + return new Promise((resolvePromise, reject) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (chunk: Buffer) => (stdout += chunk.toString())); + child.stderr.on("data", (chunk: Buffer) => (stderr += chunk.toString())); + child.on("error", reject); + child.on("close", (code) => resolvePromise({ code: code ?? 1, stderr, stdout })); + }); +} + +async function existingStackIsRunning(root: string): Promise { + const localEnv = join(root, ".env.local"); + const result = await captureCommand("docker", [ + "compose", + "--env-file", + localEnv, + "ps", + "--status", + "running", + "-q", + "app", + ]).catch(() => undefined); + return Boolean(result?.stdout.trim()); +} + +function assertPortAvailable(port: number): Promise { + return new Promise((resolvePromise, reject) => { + const server = createServer(); + server.unref(); + server.once("error", () => + reject(new Error(`Port preflight failed: 127.0.0.1:${port} is busy.`)), + ); + server.listen(port, "127.0.0.1", () => server.close(() => resolvePromise())); + }); +} + +async function localEndpointsAreReady(): Promise { + try { + const request = { signal: AbortSignal.timeout(2_000) }; + const [web, gateway] = await Promise.all([ + fetch("http://127.0.0.1:3001/cheatcode-symbol.png", request), + fetch("http://127.0.0.1:8787/health/live", request), + ]); + const body: unknown = await gateway.json(); + return ( + web.ok && + web.headers.get("content-type")?.startsWith("image/png") === true && + gateway.ok && + isRecord(body) && + body["ok"] === true + ); + } catch { + return false; + } +} + +function parseDatabaseUrl(raw: string, label: string): URL { + try { + const url = new URL(raw); + if (url.protocol !== "postgres:" && url.protocol !== "postgresql:") { + throw new Error("invalid protocol"); + } + return url; + } catch { + throw new Error(`${label} must be a PostgreSQL connection URL.`); + } +} + +function decodeComponent(value: string, label: string): string { + try { + return decodeURIComponent(value); + } catch { + throw new Error(`${label} must be valid URL-encoded text.`); + } +} + +function isSupabasePoolerHost(hostname: string): boolean { + try { + validateSupabasePoolerHost(hostname); + return true; + } catch { + return false; + } +} + +function directAdminProjectRef(hostname: string): string | undefined { + const match = /^db\.([a-z0-9]+)\.supabase\.co$/u.exec(hostname); + if (!match?.[1]) { + return undefined; + } + return parseSupabaseProjectRef(match[1], "Admin URL project ref"); +} + +function poolerAdminProjectRef(hostname: string, username: string): string | undefined { + if (!isSupabasePoolerHost(hostname) || !username.startsWith("postgres.")) { + return undefined; + } + return parseSupabaseProjectRef(username.slice("postgres.".length), "Admin URL project ref"); +} + +function errorCode(error: unknown): string | undefined { + return isRecord(error) && typeof error["code"] === "string" ? error["code"] : undefined; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/scripts/setup.ts b/scripts/setup.ts new file mode 100644 index 00000000..e3ea326f --- /dev/null +++ b/scripts/setup.ts @@ -0,0 +1,343 @@ +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { confirm, intro, isCancel, log, note, outro } from "@clack/prompts"; +import { + provisionDatabase, + type RuntimeDatabaseCredentials, + readAdminDatabaseIdentity, + verifyDatabaseSetup, + verifyMigrationLedger, +} from "./db-provision"; +import { + OPTIONAL_LOCAL_ENV_KEYS, + PINNED_LOCAL_ENV_VALUES, + REQUIRED_WEB_ENV, + REQUIRED_WORKER_ENV, + validateLocalEnvironment, + validateSupabaseRuntimeDatabaseUrls, +} from "./local-env-contract"; +import { collectSetupValues, confirmUnknownKeyRemoval } from "./setup-prompts"; +import { + type AdminDatabaseTarget, + type MigrationEnvironment, + migrationEnvironment, + parseAdminDatabaseUrl, + pollLocalReadiness, + readOptionalEnvFile, + runInheritedCommand, + runSetupPreflight, + sanitizedMigrationChildEnvironment, + writeEnvFileAtomic, +} from "./setup-support"; + +const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const LOCAL_ENV_PATH = join(ROOT, ".env.local"); +const MIGRATE_ENV_PATH = join(ROOT, ".env.migrate"); + +const LOCAL_ENV_ORDER = [ + "SUPABASE_GATEWAY_DATABASE_URL", + "SUPABASE_AGENT_DATABASE_URL", + "SUPABASE_WEBHOOKS_DATABASE_URL", + "NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY", + "CLERK_SECRET_KEY", + "CLERK_WEBHOOK_SIGNING_SECRET", + "NEXT_PUBLIC_GATEWAY_URL", + "DAYTONA_API_KEY", + "DAYTONA_API_URL", + "DAYTONA_PREVIEW_HOST_SUFFIXES", + "DAYTONA_SANDBOX_SNAPSHOT", + "DAYTONA_TARGET", + "DAYTONA_WORKSPACE_VOLUME", + "DAYTONA_WEBHOOK_SIGNING_SECRET", + "DAYTONA_ORG_ID", + "COMPOSIO_API_KEY", + "COMPOSIO_AUTH_CONFIGS", + "COMPOSIO_WEBHOOK_SECRET", + "DEEPSEEK_PLATFORM_API_KEY", + "POLAR_ACCESS_TOKEN", + "POLAR_SERVER", + "POLAR_WEBHOOK_SECRET", + "POLAR_PRODUCT_ID_PRO", + "POLAR_PRODUCT_ID_PREMIUM", + "DATABASE_CONTEXT_SIGNING_SECRET_AGENT", + "DATABASE_CONTEXT_SIGNING_SECRET_GATEWAY", + "DATABASE_CONTEXT_SIGNING_SECRET_WEBHOOKS", + "PREVIEW_TOKEN_SECRET", + "OUTPUT_DOWNLOAD_SIGNING_SECRET", +] as const; + +const MIGRATE_ENV_ORDER = [ + "SUPABASE_MIGRATION_URL", + "SUPABASE_MIGRATION_EXPECTED_HOST", + "SUPABASE_MIGRATION_EXPECTED_DATABASE", + "SUPABASE_MIGRATION_EXPECTED_ROLE", + "SUPABASE_MIGRATION_EXPECTED_SYSTEM_IDENTIFIER", +] as const; + +const LOCAL_ENV_KEYS = new Set([ + ...REQUIRED_WORKER_ENV, + ...REQUIRED_WEB_ENV, + ...OPTIONAL_LOCAL_ENV_KEYS, + ...Object.keys(PINNED_LOCAL_ENV_VALUES), +]); +const MIGRATE_ENV_KEYS = new Set(MIGRATE_ENV_ORDER); + +async function runInteractiveSetup(): Promise { + intro("Cheatcode self-host setup"); + await runStep("host preflight", () => runSetupPreflight(ROOT)); + const [existingLocal, existingMigrate] = await runStep("environment file read", () => + Promise.all([readOptionalEnvFile(LOCAL_ENV_PATH), readOptionalEnvFile(MIGRATE_ENV_PATH)]), + ); + const retainedLocal = await confirmUnknownKeyRemoval(existingLocal, LOCAL_ENV_KEYS, ".env.local"); + const retainedMigrate = await confirmUnknownKeyRemoval( + existingMigrate, + MIGRATE_ENV_KEYS, + ".env.migrate", + ); + const collected = await collectSetupValues(retainedLocal, retainedMigrate); + await runStep("local environment validation", async () => { + validateLocalEnvironment(collected.localValues, { webOnly: false, workersOnly: false }); + }); + const shouldApply = await confirmApply(collected.adminTarget); + if (!shouldApply) { + outro("Setup cancelled. No files or database state were changed."); + return; + } + const identity = await runStep("Supabase identity read", () => + readAdminDatabaseIdentity(collected.adminTarget.url), + ); + assertAdminIdentity(collected.adminTarget, identity.database, identity.role); + const migrateValues = migrationEnvironment(collected.adminTarget, identity.systemIdentifier); + await writeSetupFiles(collected.localValues, { ...retainedMigrate, ...migrateValues }); + await applyAndVerify(collected.localValues, collected.rolePasswords, migrateValues); + await offerStackStart(collected.localValues); +} + +async function applyAndVerify( + localValues: Record, + rolePasswords: Parameters[0]["rolePasswords"], + migrateValues: MigrationEnvironment, +): Promise { + await runStep("database migrations", () => + runInheritedCommand( + "pnpm", + ["db:migrate", "--apply"], + ROOT, + sanitizedMigrationChildEnvironment(migrateValues), + ), + ); + const credentials = runtimeCredentials(localValues); + await runStep("database provisioning", () => + provisionDatabase({ + adminDatabaseUrl: migrateValues.SUPABASE_MIGRATION_URL, + rolePasswords, + runtimeCredentials: credentials, + }), + ); + await runStep("runtime database probes", () => verifyDatabaseSetup(credentials)); + await runStep("local environment validation", async () => { + validateLocalEnvironment(localValues, { webOnly: false, workersOnly: false }); + }); +} + +async function runCheck(): Promise { + intro("Cheatcode setup check"); + await runStep("host preflight", () => runSetupPreflight(ROOT)); + const [localValues, migrateValues] = await runStep("environment file read", () => + Promise.all([readOptionalEnvFile(LOCAL_ENV_PATH), readOptionalEnvFile(MIGRATE_ENV_PATH)]), + ); + const migration = await runStep("environment validation", async () => { + validateLocalEnvironment(localValues, { webOnly: false, workersOnly: false }); + const runtimeTarget = validateSupabaseRuntimeDatabaseUrls(localValues); + return parseMigrationEnvironment(migrateValues, runtimeTarget.projectRef); + }); + await runStep("migration ledger and database connectivity", () => + verifyMigrationLedger(migration.SUPABASE_MIGRATION_URL, { + expectedDatabase: migration.SUPABASE_MIGRATION_EXPECTED_DATABASE, + expectedHost: migration.SUPABASE_MIGRATION_EXPECTED_HOST, + expectedRole: migration.SUPABASE_MIGRATION_EXPECTED_ROLE, + expectedSystemIdentifier: migration.SUPABASE_MIGRATION_EXPECTED_SYSTEM_IDENTIFIER, + }), + ); + await runStep("runtime database probes", () => + verifyDatabaseSetup(runtimeCredentials(localValues)), + ); + outro("Setup check passed. No files or database state were changed."); +} + +async function writeSetupFiles( + localValues: Record, + migrateValues: Record, +): Promise { + await runStep("environment file write", async () => { + await writeEnvFileAtomic(LOCAL_ENV_PATH, localValues, LOCAL_ENV_ORDER, [ + "Generated by pnpm dev:setup. Re-run the wizard to change or rotate values.", + "Application credentials only; administrative credentials stay in .env.migrate.", + ]); + await writeEnvFileAtomic(MIGRATE_ENV_PATH, migrateValues, MIGRATE_ENV_ORDER, [ + "Generated by pnpm dev:setup. Never load this administrative file into the app.", + ]); + }); +} + +async function confirmApply(target: AdminDatabaseTarget): Promise { + note(`Host: ${target.hostname}\nDatabase: ${target.database}`, "Migration target"); + const result = await confirm({ + initialValue: false, + message: "Apply migrations and provision this dedicated Supabase project?", + }); + return isCancel(result) ? false : result; +} + +async function offerStackStart(localValues: Record): Promise { + const answer = await confirm({ initialValue: false, message: "Start the stack now?" }); + if (isCancel(answer) || !answer) { + printStartInstructions("The stack was not started."); + outro("Setup complete."); + return; + } + await runStep("local stack start", () => + runInheritedCommand( + "docker", + [ + "compose", + "--env-file", + ".env.local", + "up", + "-d", + "--build", + "--force-recreate", + "--wait", + "app", + ], + ROOT, + ), + ); + await runStep("local readiness", () => pollLocalReadiness()); + printFeatureSummary(localValues); + printStartInstructions("The stack is running."); + outro("Cheatcode is ready."); +} + +function runtimeCredentials(values: Record): RuntimeDatabaseCredentials[] { + return [ + runtimeCredential(values, "app_gateway", "GATEWAY"), + runtimeCredential(values, "app_agent", "AGENT"), + runtimeCredential(values, "app_webhooks", "WEBHOOKS"), + ]; +} + +function runtimeCredential( + values: Record, + role: RuntimeDatabaseCredentials["role"], + suffix: "AGENT" | "GATEWAY" | "WEBHOOKS", +): RuntimeDatabaseCredentials { + const databaseUrl = values[`SUPABASE_${suffix}_DATABASE_URL`]; + const signingSecret = values[`DATABASE_CONTEXT_SIGNING_SECRET_${suffix}`]; + if (!databaseUrl || !signingSecret) { + throw new Error(`Missing runtime database credentials for ${role}.`); + } + return { databaseUrl, role, signingSecret }; +} + +function parseMigrationEnvironment( + values: Record, + projectRef: string, +): MigrationEnvironment { + for (const key of MIGRATE_ENV_ORDER) { + if (!values[key]) { + throw new Error(`.env.migrate is missing ${key}. Run pnpm dev:setup.`); + } + } + const target = parseAdminDatabaseUrl(values["SUPABASE_MIGRATION_URL"] ?? "", projectRef); + if ( + values["SUPABASE_MIGRATION_EXPECTED_HOST"] !== target.hostname || + values["SUPABASE_MIGRATION_EXPECTED_DATABASE"] !== target.database || + values["SUPABASE_MIGRATION_EXPECTED_ROLE"] !== target.role + ) { + throw new Error(".env.migrate identity pins do not match its admin connection string."); + } + return values as unknown as MigrationEnvironment; +} + +function assertAdminIdentity( + target: AdminDatabaseTarget, + actualDatabase: string, + actualRole: string, +): void { + if (actualDatabase !== target.database || actualRole !== target.role) { + throw new Error( + `Supabase identity mismatch: expected ${target.role}@${target.database}, got ${actualRole}@${actualDatabase}.`, + ); + } +} + +function printFeatureSummary(values: Record): void { + const enabled = [ + values["POLAR_ACCESS_TOKEN"] ? "Polar sandbox billing" : undefined, + values["COMPOSIO_API_KEY"] ? "Composio connected apps" : undefined, + values["DEEPSEEK_PLATFORM_API_KEY"] ? "DeepSeek platform fallback" : undefined, + ].filter((value): value is string => Boolean(value)); + log.info( + "Core features: agent runs, Daytona workspaces and previews, generated files, and BYOK.", + ); + log.info( + `Optional features: ${enabled.join(", ") || "none (core BYOK flows remain available)"}.`, + ); +} + +function printStartInstructions(status: string): void { + note( + [ + status, + "Start/restart: docker compose --env-file .env.local up -d --build --force-recreate --wait app", + "Web: http://localhost:3001", + "Gateway: http://127.0.0.1:8787", + "Inspector: http://127.0.0.1:9239", + "Use 127.0.0.1 for gateway cookies; localhost and 127.0.0.1 are different cookie sites.", + ].join("\n"), + "Local URLs", + ); +} + +async function runStep(label: string, action: () => Promise): Promise { + log.step(label); + try { + return await action(); + } catch (error) { + const message = error instanceof Error ? error.message : "Unknown failure."; + throw new Error( + `Setup failed during ${label}: ${message} Re-run pnpm dev:setup to resume safely; completed steps are idempotent.`, + ); + } +} + +function parseMode(argv: string[]): "check" | "interactive" { + const args = argv.filter((arg) => arg !== "--"); + if (args.length === 0) { + return "interactive"; + } + if (args.length === 1 && args[0] === "--check") { + return "check"; + } + if (args.length === 1 && (args[0] === "--help" || args[0] === "-h")) { + process.stdout.write("Usage: pnpm dev:setup [--check]\n"); + process.exit(0); + } + throw new Error(`Unknown setup option: ${args.join(" ")}`); +} + +async function main(): Promise { + const mode = parseMode(process.argv.slice(2)); + if (mode === "check") { + await runCheck(); + return; + } + await runInteractiveSetup(); +} + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : "Unknown setup failure."; + log.error(message); + process.exitCode = 1; +}); diff --git a/scripts/supabase-target/index.ts b/scripts/supabase-target/index.ts index 050b9f36..6173247d 100644 --- a/scripts/supabase-target/index.ts +++ b/scripts/supabase-target/index.ts @@ -285,6 +285,16 @@ function validateRelationAcl( } async function validateDataApiIsolation(client: PgClient): Promise { + const checks = await Promise.all([ + validateDataApiRelationAccess(client), + validateDataApiFunctionAccess(client), + validateDataApiSchemaAccess(client), + validateDataApiDefaultAcl(client), + ]); + return checks.flat(); +} + +async function validateDataApiRelationAccess(client: PgClient): Promise { const result = await client.query( `select role.rolname, relation.relname, 'table' as access_kind, privilege.name from pg_roles role @@ -304,7 +314,14 @@ async function validateDataApiIsolation(client: PgClient): Promise { and has_any_column_privilege(role.oid, relation.oid, privilege.name)`, [[...DATA_API_ROLES]], ); - const functionResult = await client.query( + return result.rows.map( + (row) => + `Data API role ${stringField(row, "rolname")} retains ${stringField(row, "name")} ${stringField(row, "access_kind")} access on public.${stringField(row, "relname")}.`, + ); +} + +async function validateDataApiFunctionAccess(client: PgClient): Promise { + const result = await client.query( `select role.rolname, procedure.oid::regprocedure::text as identity from pg_roles role join pg_proc procedure on true @@ -316,16 +333,60 @@ async function validateDataApiIsolation(client: PgClient): Promise { and has_function_privilege(role.oid, procedure.oid, 'EXECUTE')`, [[...DATA_API_ROLES]], ); - return [ - ...result.rows.map( - (row) => - `Data API role ${stringField(row, "rolname")} retains ${stringField(row, "name")} ${stringField(row, "access_kind")} access on public.${stringField(row, "relname")}.`, - ), - ...functionResult.rows.map( - (row) => - `Data API role ${stringField(row, "rolname")} retains EXECUTE on ${stringField(row, "identity")}.`, - ), - ]; + return result.rows.map( + (row) => + `Data API role ${stringField(row, "rolname")} retains EXECUTE on ${stringField(row, "identity")}.`, + ); +} + +async function validateDataApiSchemaAccess(client: PgClient): Promise { + const result = await client.query( + `select role.rolname, + has_schema_privilege(role.oid, 'public', 'USAGE') as can_use, + has_schema_privilege(role.oid, 'public', 'CREATE') as can_create + from pg_roles role + where role.rolname = any($1::text[])`, + [[...DATA_API_ROLES]], + ); + return result.rows + .filter((row) => row["can_use"] === true || row["can_create"] === true) + .map( + (row) => `Data API role ${stringField(row, "rolname")} retains privileges on schema public.`, + ); +} + +async function validateDataApiDefaultAcl(client: PgClient): Promise { + const result = await client.query( + `select owner.rolname as owner_name, + case object_type.value + when 'r' then 'table' + when 'S' then 'sequence' + when 'f' then 'function' + else object_type.value::text + end as object_kind, + coalesce(grantee.rolname, 'PUBLIC') as grantee_name, + (entry).privilege_type as privilege + from pg_roles owner + cross join (values ('r'::"char"), ('S'::"char"), ('f'::"char")) object_type(value) + join pg_namespace namespace on namespace.nspname = 'public' + left join pg_default_acl defaults + on defaults.defaclrole = owner.oid + and defaults.defaclnamespace = namespace.oid + and defaults.defaclobjtype = object_type.value + cross join lateral aclexplode( + coalesce(defaults.defaclacl, acldefault(object_type.value, owner.oid)) + ) entry + left join pg_roles grantee on grantee.oid = (entry).grantee + -- supabase_admin's default ACLs are Supabase-managed platform state and + -- apply only to supabase_admin-created objects, which this schema never creates. + where owner.rolname = 'postgres' + and ((entry).grantee = 0 or grantee.rolname = any($1::text[]))`, + [[...DATA_API_ROLES]], + ); + return result.rows.map( + (row) => + `Default privileges for ${stringField(row, "owner_name")} grant ${stringField(row, "privilege")} on future public ${stringField(row, "object_kind")} objects to ${stringField(row, "grantee_name")}.`, + ); } function runtimeAclQuery(): string {