diff --git a/.github/workflows/backend-quality-gate.yml b/.github/workflows/backend-quality-gate.yml index 5f0a3ca0..da169138 100644 --- a/.github/workflows/backend-quality-gate.yml +++ b/.github/workflows/backend-quality-gate.yml @@ -74,9 +74,6 @@ jobs: path: artifacts/quick-quality-gate.log - name: Validate frontend and database contracts - env: - NEXT_PUBLIC_SUPABASE_URL: https://placeholder.supabase.co - NEXT_PUBLIC_SUPABASE_ANON_KEY: placeholder run: | npm test --prefix frontend npm run lint --prefix frontend @@ -93,24 +90,6 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Validate staging web build variables - env: - STAGING_SUPABASE_URL: ${{ vars.STAGING_SUPABASE_URL }} - STAGING_SUPABASE_ANON_KEY: ${{ vars.STAGING_SUPABASE_ANON_KEY }} - run: | - test -n "$STAGING_SUPABASE_URL" || { - echo "STAGING_SUPABASE_URL is required" >&2 - exit 1 - } - test -n "$STAGING_SUPABASE_ANON_KEY" || { - echo "STAGING_SUPABASE_ANON_KEY is required" >&2 - exit 1 - } - if [[ ! "$STAGING_SUPABASE_URL" =~ ^https://[a-z0-9][a-z0-9-]*\.supabase\.co/?$ ]]; then - echo "STAGING_SUPABASE_URL must be an HTTPS Supabase project URL" >&2 - exit 1 - fi - - name: Log in to GHCR uses: docker/login-action@v3 with: @@ -135,9 +114,6 @@ jobs: file: deploy/railway-web.Dockerfile push: true tags: ghcr.io/jesse-ux/jyotisha-web:${{ github.sha }} - build-args: | - NEXT_PUBLIC_SUPABASE_URL=${{ vars.STAGING_SUPABASE_URL }} - NEXT_PUBLIC_SUPABASE_ANON_KEY=${{ vars.STAGING_SUPABASE_ANON_KEY }} - name: Record immutable staging image manifest env: diff --git a/deploy/.env.staging.identity.example b/deploy/.env.staging.identity.example index 5dee8521..c74373bb 100644 --- a/deploy/.env.staging.identity.example +++ b/deploy/.env.staging.identity.example @@ -5,14 +5,18 @@ CADDYFILE_PATH=./Caddyfile.staging SITE_ADDRESS=https://staging.jyotisha.chat ADMIN_SITE_ADDRESS=https://admin.staging.jyotisha.chat -# Coexistence mode: the public site still uses Supabase-backed business routes, -# while the self-hosted identity API and the admin-host login can be tested. -AUTH_PROVIDER=supabase +# Staging-only cutover: identity and business data both use the private local +# PostgreSQL service. Production remains on Supabase until a separate cutover. +AUTH_PROVIDER=self-hosted SELF_HOSTED_IDENTITY_ENABLED=true AUTH_USER_ORIGIN=https://staging.jyotisha.chat AUTH_ADMIN_ORIGIN=https://admin.staging.jyotisha.chat IDENTITY_DATABASE_URL=postgresql://identity_runtime:@postgres:5432/jyotisha +APP_DATABASE_URL=postgresql://app_runtime:@postgres:5432/jyotisha +ADMIN_DATABASE_URL=postgresql://admin_runtime:@postgres:5432/jyotisha BETTER_AUTH_USER_SECRET= BETTER_AUTH_ADMIN_SECRET= RESEND_API_KEY= RESEND_FROM_EMAIL=Jyotisha Staging +ADMIN_EMAILS= +JYOTISH_DYNAMIC_RECTIFICATION_TOKEN= diff --git a/deploy/Caddyfile.staging b/deploy/Caddyfile.staging index ebeb3caf..0daab86e 100644 --- a/deploy/Caddyfile.staging +++ b/deploy/Caddyfile.staging @@ -1,13 +1,20 @@ {$SITE_ADDRESS:https://staging.jyotisha.chat} { encode zstd gzip + + @adminPaths path /admin /admin/* /api/admin/* + respond @adminPaths "Not found" 404 + reverse_proxy web:3000 } {$ADMIN_SITE_ADDRESS:https://admin.staging.jyotisha.chat} { encode zstd gzip - @identity path /login /api/auth/* /_next/* /jyotish-logo.png /favicon.ico - handle @identity { + @adminRoot path / + redir @adminRoot /admin/codes 302 + + @adminSurface path /login /admin /admin/* /api/admin/* /api/auth/* /_next/* /jyotish-logo.png /favicon.ico + handle @adminSurface { reverse_proxy web:3000 } respond "Not found" 404 diff --git a/deploy/README.md b/deploy/README.md index e0328d92..39301549 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -152,12 +152,11 @@ Staging is isolated from production: | Runtime app env | `/opt/jyotisha-staging/.env.staging` (`0600`) | | Runtime database env | `/opt/jyotisha-staging/.env.staging.database` (`0600`) | | PostgreSQL | private Compose network; no published host port | -| Supabase | separate `Jyotisha Staging` project | +| Business database | local private PostgreSQL (`jyotisha-staging` Compose project) | +| Identity | Better Auth + Resend OTP on the same private PostgreSQL cluster | | GitHub Environment | `staging` | -The GitHub `staging` Environment contains the secret `STAGING_SSH_PRIVATE_KEY` and the variables `STAGING_HOST`, `STAGING_PORT`, `STAGING_USER`, `STAGING_PATH`, `STAGING_URL`, and `STAGING_KNOWN_HOSTS`. Its deployment branch policy allows the `main` controller branch: GitHub's `workflow_run` event executes from the default branch while the workflow separately requires the successfully tested upstream branch to be `staging`. The controller checks out only `main` with full history, requires the requested staging SHA to be an ancestor of that reviewed history, and uploads only the allowlisted `deploy/` control files. It never executes deployment validators or remote orchestration scripts from the target/rollback revision. The staging key, database, Supabase keys, and model-provider keys must not be shared with production. - -The repository-level public build inputs are configured at GitHub **Settings -> Secrets and variables -> Actions -> Variables** (the UI is also shown as **Settings → Secrets and variables → Actions → Variables**): `STAGING_SUPABASE_URL` and `STAGING_SUPABASE_ANON_KEY`. They are public build inputs, required for publish, and exposed to the browser; keep them staging-only and never print their values in workflow output, summaries, or support messages. The workflow passes them only as the `NEXT_PUBLIC_*` build arguments after non-empty/HTTPS validation. +The GitHub `staging` Environment contains the secret `STAGING_SSH_PRIVATE_KEY` and the variables `STAGING_HOST`, `STAGING_PORT`, `STAGING_USER`, `STAGING_PATH`, `STAGING_URL`, and `STAGING_KNOWN_HOSTS`. Its deployment branch policy allows the `main` controller branch: GitHub's `workflow_run` event executes from the default branch while the workflow separately requires the successfully tested upstream branch to be `staging`. The controller checks out only `main` with full history, requires the requested staging SHA to be an ancestor of that reviewed history, and uploads only the allowlisted `deploy/` control files. It never executes deployment validators or remote orchestration scripts from the target/rollback revision. The staging key, database, Resend key, and model-provider keys must not be shared with production. Staging image publishing has no Supabase build variables. `Staging Backend Quality Gate` runs for relevant `pull_request` paths, pushes to `staging`, and `workflow_dispatch`. It validates the Python/database/frontend contract; only a successful push to `staging` publishes the API/web images and a run-bound manifest containing their `sha256` digests. `.github/workflows/deploy-staging.yml` consumes that exact successful run, validates its manifest against the full 40-character commit, and deploys digest references rather than trusting the discoverability tags. @@ -170,13 +169,13 @@ SITE_ADDRESS=https://staging.jyotisha.chat ADMIN_SITE_ADDRESS=https://admin.staging.jyotisha.chat ``` -The self-hosted identity milestone runs in coexistence mode: keep `AUTH_PROVIDER=supabase` and set `SELF_HOSTED_IDENTITY_ENABLED=true`. Add the server-only identity database, separate user/admin Better Auth secrets, origins, and staging-only Resend settings listed in `deploy/.env.staging.identity.example`. The public login remains on Supabase while the admin host and identity API are exercised. Do not set `AUTH_PROVIDER=self-hosted` until the Supabase-backed business modules have migrated. See `docs/operations/self-hosted-identity.md` for validation, import, smoke, and rollback commands. +Staging is fully self-hosted: set `AUTH_PROVIDER=self-hosted` and `SELF_HOSTED_IDENTITY_ENABLED=true`. Add the three role-specific server-only database URLs, separate user/admin Better Auth secrets, origins, and staging-only Resend settings listed in `deploy/.env.staging.identity.example`. Browser code uses same-origin APIs; it receives neither database credentials nor Supabase keys. Production remains on Supabase and is not changed by the staging workflow. See `docs/operations/self-hosted-identity.md` for validation and rollback commands. After source sync and before `up`, the workflow validates `.env.staging` mode/selectors, explicitly pins the three staging selectors against ambient shell overrides, and runs `docker compose --env-file .env.staging -f deploy/docker-compose.server.yml config --quiet`. For later manual inspections, run the same checks only after the tracked deployment files exist on the server. Do not use a manual gate run from `main` as the first publishing path: publishing requires a successful push to `staging`, while manual `Deploy staging` requires a successful gate run for the exact SHA. ### First-deploy sequence -1. Complete the server and GitHub bootstrap: create both mode-`0600` env files, preload the reviewed `postgres:17-alpine` image, configure the staging Environment variables/secrets, and configure the repository staging build variables. Deployment and migration workflows use `--pull never` for PostgreSQL, so database image upgrades remain an explicit operator-controlled maintenance action rather than an application-deploy side effect. +1. Complete the server and GitHub bootstrap: create both mode-`0600` env files, preload the reviewed `postgres:17-alpine` image, and configure the staging Environment variables/secrets. No repository-level Supabase variables are required. Deployment and migration workflows use `--pull never` for PostgreSQL, so database image upgrades remain an explicit operator-controlled maintenance action rather than an application-deploy side effect. 2. Merge the reviewed change to `main`, then fast-forward/push that exact reviewed SHA to `staging`; do not create a staging-only target or rely on a `main` workflow dispatch to publish images. 3. The `Staging Backend Quality Gate` runs for that push and, when successful, publishes API/web images plus an artifact binding the exact SHA to both immutable image digests. 4. The automatic `Deploy staging` workflow downloads that gate-run artifact, syncs only the trusted `main` controller's allowlisted `deploy/` files under the shared staging host lock, and validates both `.env.staging` and `.env.staging.database` before any app change. The target application's code is carried only by the digest-pinned images. @@ -196,7 +195,7 @@ docker compose --env-file .env.staging -f deploy/docker-compose.server.yml logs curl -fsS https://staging.jyotisha.chat/api/health ``` -The normal application deployment workflow never runs database migrations. Apply migrations to the separate staging project first, verify them, and only then deploy application code that depends on them. +The normal application deployment workflow never runs database migrations. Apply migrations to the private staging PostgreSQL cluster first, verify them, and only then deploy application code that depends on them. ## Staging PostgreSQL operations diff --git a/deploy/docker-compose.server.yml b/deploy/docker-compose.server.yml index d03f0c2f..2ceab51d 100644 --- a/deploy/docker-compose.server.yml +++ b/deploy/docker-compose.server.yml @@ -26,8 +26,8 @@ services: context: .. dockerfile: deploy/railway-web.Dockerfile args: - NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL} - NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY} + NEXT_PUBLIC_SUPABASE_URL: ${NEXT_PUBLIC_SUPABASE_URL:-} + NEXT_PUBLIC_SUPABASE_ANON_KEY: ${NEXT_PUBLIC_SUPABASE_ANON_KEY:-} restart: unless-stopped env_file: - ${APP_ENV_FILE:-../.env.production} diff --git a/deploy/postgres/001-bootstrap-roles.sh b/deploy/postgres/001-bootstrap-roles.sh index cee4a948..84de2513 100755 --- a/deploy/postgres/001-bootstrap-roles.sh +++ b/deploy/postgres/001-bootstrap-roles.sh @@ -48,6 +48,17 @@ SELECT format( ) WHERE NOT EXISTS ( SELECT 1 FROM pg_roles WHERE rolname = 'admin_runtime' ) \gexec + +SELECT 'CREATE ROLE anon NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT' +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'anon') \gexec +SELECT 'CREATE ROLE authenticated NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT' +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'authenticated') \gexec +SELECT 'CREATE ROLE service_role NOLOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT BYPASSRLS' +WHERE NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'service_role') \gexec +ALTER ROLE service_role BYPASSRLS; + +GRANT authenticated TO app_runtime; +GRANT service_role TO admin_runtime; SELECT format( 'CREATE ROLE migration_runner WITH LOGIN NOSUPERUSER NOCREATEDB NOCREATEROLE NOINHERIT PASSWORD %L', :'migration_runner_password' diff --git a/deploy/postgres/002-ensure-business-compatibility-roles.sql b/deploy/postgres/002-ensure-business-compatibility-roles.sql new file mode 100644 index 00000000..01fb3cac --- /dev/null +++ b/deploy/postgres/002-ensure-business-compatibility-roles.sql @@ -0,0 +1,11 @@ +select 'create role anon nologin nosuperuser nocreatedb nocreaterole noinherit' +where not exists (select 1 from pg_roles where rolname = 'anon') \gexec +select 'create role authenticated nologin nosuperuser nocreatedb nocreaterole noinherit' +where not exists (select 1 from pg_roles where rolname = 'authenticated') \gexec +select 'create role service_role nologin nosuperuser nocreatedb nocreaterole noinherit bypassrls' +where not exists (select 1 from pg_roles where rolname = 'service_role') \gexec + +alter role service_role bypassrls; + +grant authenticated to app_runtime; +grant service_role to admin_runtime; diff --git a/deploy/railway-web.Dockerfile b/deploy/railway-web.Dockerfile index 5aa2ca5e..5926686d 100644 --- a/deploy/railway-web.Dockerfile +++ b/deploy/railway-web.Dockerfile @@ -9,6 +9,7 @@ COPY frontend/public ./public COPY frontend/next.config.ts frontend/postcss.config.mjs frontend/tsconfig.json ./ COPY frontend/scripts ./scripts COPY frontend/db ./db +COPY frontend/supabase/migrations ./supabase/migrations ARG NEXT_PUBLIC_SUPABASE_URL ARG NEXT_PUBLIC_SUPABASE_ANON_KEY diff --git a/deploy/run-staging-migration.sh b/deploy/run-staging-migration.sh index cc0b9ee8..00d611f7 100755 --- a/deploy/run-staging-migration.sh +++ b/deploy/run-staging-migration.sh @@ -74,6 +74,8 @@ export DATABASE_ENV_FILE='../.env.staging.database' compose=(docker compose -p jyotisha-staging -f deploy/docker-compose.postgres.yml) docker pull "$WEB_IMAGE" "${compose[@]}" up -d --no-build --pull never --wait postgres +"${compose[@]}" exec -T postgres psql -v ON_ERROR_STOP=1 -U postgres -d jyotisha \ + -f /dev/stdin < deploy/postgres/002-ensure-business-compatibility-roles.sql "${compose[@]}" --profile migration run --rm migrator "${compose[@]}" exec -T postgres psql -U postgres -d jyotisha -Atc \ 'select filename from migration.schema_migrations order by filename' diff --git a/deploy/validate-staging-env.sh b/deploy/validate-staging-env.sh index 9ba55d99..d54605cb 100755 --- a/deploy/validate-staging-env.sh +++ b/deploy/validate-staging-env.sh @@ -42,7 +42,7 @@ require_selector APP_ENV_FILE ../.env.staging require_selector CADDYFILE_PATH ./Caddyfile.staging require_selector SITE_ADDRESS https://staging.jyotisha.chat require_selector ADMIN_SITE_ADDRESS https://admin.staging.jyotisha.chat -require_selector AUTH_PROVIDER supabase +require_selector AUTH_PROVIDER self-hosted require_selector SELF_HOSTED_IDENTITY_ENABLED true require_selector AUTH_USER_ORIGIN https://staging.jyotisha.chat require_selector AUTH_ADMIN_ORIGIN https://admin.staging.jyotisha.chat @@ -73,6 +73,20 @@ if ! [[ "$identity_database_url" =~ ^postgresql://identity_runtime:([A-Za-z0-9._ exit 1 fi +require_literal APP_DATABASE_URL 45 +app_database_url="$LITERAL_VALUE" +if ! [[ "$app_database_url" =~ ^postgresql://app_runtime:([A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+@postgres:5432/jyotisha$ ]]; then + echo "invalid staging database setting: APP_DATABASE_URL" >&2 + exit 1 +fi + +require_literal ADMIN_DATABASE_URL 45 +admin_database_url="$LITERAL_VALUE" +if ! [[ "$admin_database_url" =~ ^postgresql://admin_runtime:([A-Za-z0-9._~-]|%[0-9A-Fa-f]{2})+@postgres:5432/jyotisha$ ]]; then + echo "invalid staging database setting: ADMIN_DATABASE_URL" >&2 + exit 1 +fi + require_literal BETTER_AUTH_USER_SECRET 32 user_secret="$LITERAL_VALUE" require_literal BETTER_AUTH_ADMIN_SECRET 32 @@ -87,5 +101,11 @@ if [[ "$LITERAL_VALUE" != *@* ]]; then echo "invalid staging identity setting: RESEND_FROM_EMAIL" >&2 exit 1 fi +require_literal ADMIN_EMAILS 3 +if [[ "$LITERAL_VALUE" != *@* ]]; then + echo "invalid staging identity setting: ADMIN_EMAILS" >&2 + exit 1 +fi +require_literal JYOTISH_DYNAMIC_RECTIFICATION_TOKEN 32 echo "staging environment selectors: valid" diff --git a/docs/operations/self-hosted-identity.md b/docs/operations/self-hosted-identity.md index 8292ec10..2e7009cb 100644 --- a/docs/operations/self-hosted-identity.md +++ b/docs/operations/self-hosted-identity.md @@ -1,17 +1,17 @@ # Self-hosted identity operations -This milestone deploys Better Auth beside the existing Supabase login. It does not authorize the final authentication cutover or removal of Supabase-backed business routes. +Staging uses Better Auth and the private local PostgreSQL cluster for both identity and business data. Production remains on Supabase; this runbook does not authorize a production cutover. -## Safe staging mode +## Staging mode -Keep these two values exactly as shown while profile, consultation, credits, chat, and report routes still rely on Supabase JWT/RLS: +Keep these two values exactly as shown: ```dotenv -AUTH_PROVIDER=supabase +AUTH_PROVIDER=self-hosted SELF_HOSTED_IDENTITY_ENABLED=true ``` -This combination keeps `staging.jyotisha.chat/login` on Supabase, enables `/api/auth/**` for integration tests, and makes `admin.staging.jyotisha.chat/login` use the isolated Better Auth admin surface. The public and admin sessions have different secrets and host-only cookie prefixes. The staging validator deliberately rejects `AUTH_PROVIDER=self-hosted` in this milestone. +This makes both login hosts use isolated Better Auth surfaces. Public and admin sessions have different secrets and host-only cookie prefixes. Server routes translate the Better Auth session into PostgreSQL request claims and use the reviewed existing RLS/RPC business contract. The browser uses only same-origin APIs and does not need Supabase configuration. Use [the tracked staging identity example](../../deploy/.env.staging.identity.example) as a list of names only. Replace bracketed values directly on the server and keep `/opt/jyotisha-staging/.env.staging` owned by `deploy` with mode `0600`. @@ -22,10 +22,12 @@ openssl rand -base64 32 openssl rand -base64 32 ``` -Do not reuse either value as a PostgreSQL password. `IDENTITY_DATABASE_URL` uses the existing `IDENTITY_RUNTIME_PASSWORD` from `.env.staging.database`, percent-encoded only in the URL password component. It must point to the private Compose hostname `postgres:5432/jyotisha`; never publish PostgreSQL on a host port. +Do not reuse either value as a PostgreSQL password. `IDENTITY_DATABASE_URL`, `APP_DATABASE_URL`, and `ADMIN_DATABASE_URL` use their matching passwords from `.env.staging.database`, percent-encoded only in each URL password component. All three must point to the private Compose hostname `postgres:5432/jyotisha`; never publish PostgreSQL on a host port. The Resend key must be staging-only. `RESEND_FROM_EMAIL` must use a sender/domain verified in Resend. CI never receives this key and uses an in-memory sender. +Set `ADMIN_EMAILS` to the staging administrator allowlist. Generate an independent `JYOTISH_DYNAMIC_RECTIFICATION_TOKEN` and place the same value in the shared application env consumed by the web and private API containers; do not reuse a database or Better Auth secret. + Validate without printing values: ```bash @@ -36,19 +38,19 @@ bash deploy/validate-staging-env.sh .env.staging ## Migration and smoke checks -Apply the reviewed PostgreSQL migrations through the existing `Migrate Staging Database` workflow before deploying the web image. The identity migration creates `identity.users`, `identity.sessions`, `identity.accounts`, `identity.verifications`, and `identity.otp_rate_limits` under least-privilege roles. +Apply the reviewed PostgreSQL migrations through the existing `Migrate Staging Database` workflow before deploying the web image. The workflow first ensures the compatibility roles exist, then applies the identity schema, the local `auth` compatibility layer, and all reviewed business migrations under the migration ledger. Better Auth users are transactionally projected into `auth.users`, which creates their business profile through the existing trigger. After deployment: ```bash curl -fsS https://admin.staging.jyotisha.chat/login >/dev/null curl -fsS https://admin.staging.jyotisha.chat/api/auth/get-session -test "$(curl -sS -o /dev/null -w '%{http_code}' https://admin.staging.jyotisha.chat/)" = 404 +test "$(curl -sS -o /dev/null -w '%{http_code}' https://staging.jyotisha.chat/admin/codes)" = 404 ``` -An unknown or unpromoted email cannot create an admin session. Promote an imported staging user only through a reviewed database/admin operation; the persisted `identity.users.role` value must include `admin` before the admin OTP flow can issue a cookie. +The admin root redirects to `/admin/codes`; the public host rejects `/admin` and `/api/admin` paths. An unknown or unpromoted email cannot create an admin session. Promote an imported staging user only through a reviewed database/admin operation; the persisted `identity.users.role` value must include `admin` before the admin OTP flow can issue a cookie. -## Import rehearsal +## Optional import rehearsal Export Supabase Auth users to a JSON array in the supported fixture shape, then run a redacted dry-run first: @@ -73,8 +75,8 @@ Reruns are idempotent by UUID and the whole import is transactional. Duplicate c ## Rollback and rotation -To disable the new identity service without touching Supabase login, set `SELF_HOSTED_IDENTITY_ENABLED=false`, remove the identity-only smoke check for that separately reviewed rollback revision, and redeploy. Existing self-hosted sessions become unreachable; do not delete identity rows during application rollback. +An application rollback must use a previously validated staging image and does not reverse database migrations. Existing self-hosted sessions and data remain in PostgreSQL; do not delete identity or business rows during application rollback. Returning staging to Supabase would require a separate reviewed data-reconciliation and provider-switch change, not an environment-only toggle. Rotating either Better Auth secret invalidates only that surface's existing sessions. Rotate user and admin secrets separately, restart the web service, and verify the corresponding host. Rotate a leaked Resend key in Resend first, replace the server value, then restart. Never print the old or new values. -Final `AUTH_PROVIDER=self-hosted` cutover is blocked until all business modules authorize with the self-hosted session boundary, reconciliation passes, production backups and restore drills exist, and a separate reviewed cutover plan is approved. +Production `AUTH_PROVIDER=self-hosted` remains blocked until data reconciliation passes, production backups and restore drills exist, operational monitoring is ready, and a separate reviewed production cutover plan is approved. diff --git a/frontend/db/migrations/20260714000000_local_auth_compatibility.sql b/frontend/db/migrations/20260714000000_local_auth_compatibility.sql new file mode 100644 index 00000000..4f520506 --- /dev/null +++ b/frontend/db/migrations/20260714000000_local_auth_compatibility.sql @@ -0,0 +1,43 @@ +create schema if not exists auth authorization schema_owner; + +grant usage on schema public to anon, authenticated, service_role; +grant usage on schema auth to anon, authenticated, service_role; +revoke all on schema auth from public; + +create table if not exists auth.users ( + id uuid primary key, + email text not null, + raw_user_meta_data jsonb not null default '{}'::jsonb, + email_confirmed_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create unique index if not exists auth_users_email_canonical_key + on auth.users (lower(btrim(email))); + +create or replace function auth.uid() +returns uuid +language sql +stable +as $$ + select nullif(current_setting('request.jwt.claim.sub', true), '')::uuid +$$; + +create or replace function auth.jwt() +returns jsonb +language sql +stable +as $$ + select jsonb_build_object( + 'sub', nullif(current_setting('request.jwt.claim.sub', true), ''), + 'email', nullif(current_setting('request.jwt.claim.email', true), '') + ) +$$; + +revoke all on table auth.users from public, anon, authenticated, service_role; +grant select, insert, update, delete on table auth.users to service_role; +revoke all on function auth.uid() from public, anon; +revoke all on function auth.jwt() from public, anon; +grant execute on function auth.uid() to authenticated, service_role; +grant execute on function auth.jwt() to authenticated, service_role; diff --git a/frontend/db/migrations/20260721000200_identity_business_bridge.sql b/frontend/db/migrations/20260721000200_identity_business_bridge.sql new file mode 100644 index 00000000..81f4a29d --- /dev/null +++ b/frontend/db/migrations/20260721000200_identity_business_bridge.sql @@ -0,0 +1,65 @@ +create or replace function identity.sync_user_to_business_auth() +returns trigger +language plpgsql +security definer +set search_path = pg_catalog, auth +as $$ +begin + if tg_op = 'DELETE' then + delete from auth.users where id = old.id; + return old; + end if; + + insert into auth.users ( + id, + email, + raw_user_meta_data, + email_confirmed_at, + created_at, + updated_at + ) values ( + new.id, + new.email, + jsonb_build_object('full_name', new.name), + case when new.email_verified then coalesce(new.email_verified_at, now()) end, + new.created_at, + new.updated_at + ) + on conflict (id) do update set + email = excluded.email, + raw_user_meta_data = excluded.raw_user_meta_data, + email_confirmed_at = excluded.email_confirmed_at, + updated_at = excluded.updated_at; + + return new; +end; +$$; + +drop trigger if exists identity_user_business_auth_sync on identity.users; +create trigger identity_user_business_auth_sync +after insert or update or delete on identity.users +for each row execute function identity.sync_user_to_business_auth(); + +insert into auth.users ( + id, + email, + raw_user_meta_data, + email_confirmed_at, + created_at, + updated_at +) +select + id, + email, + jsonb_build_object('full_name', name), + case when email_verified then coalesce(email_verified_at, now()) end, + created_at, + updated_at +from identity.users +on conflict (id) do update set + email = excluded.email, + raw_user_meta_data = excluded.raw_user_meta_data, + email_confirmed_at = excluded.email_confirmed_at, + updated_at = excluded.updated_at; + +revoke all on function identity.sync_user_to_business_auth() from public; diff --git a/frontend/scripts/db-migrate.mjs b/frontend/scripts/db-migrate.mjs index 8433dbc9..5333dea8 100644 --- a/frontend/scripts/db-migrate.mjs +++ b/frontend/scripts/db-migrate.mjs @@ -9,34 +9,56 @@ const migrationFilenamePattern = /^\d{14}_[a-z0-9_]+\.sql$/; class SafeMigrationError extends Error {} -async function loadMigrationFiles(migrationsDirectory) { - let entries; - try { - entries = await readdir(migrationsDirectory, { withFileTypes: true }); - } catch { - throw new SafeMigrationError("unable to read migrations directory"); +async function loadMigrationFiles(migrationsDirectories) { + const directories = Array.isArray(migrationsDirectories) + ? migrationsDirectories + : [migrationsDirectories]; + const entriesByDirectory = []; + for (const migrationsDirectory of directories) { + try { + entriesByDirectory.push({ + migrationsDirectory, + entries: await readdir(migrationsDirectory, { withFileTypes: true }), + }); + } catch { + throw new SafeMigrationError("unable to read migrations directory"); + } } - const malformedSqlEntry = entries.find( - (entry) => - entry.isFile() && - entry.name.endsWith(".sql") && - !migrationFilenamePattern.test(entry.name), - ); + const malformedSqlEntry = entriesByDirectory + .flatMap(({ entries }) => entries) + .find( + (entry) => + entry.isFile() && + entry.name.endsWith(".sql") && + !migrationFilenamePattern.test(entry.name), + ); if (malformedSqlEntry) { throw new SafeMigrationError( `invalid migration filename: ${malformedSqlEntry.name}`, ); } + const migrationEntries = entriesByDirectory.flatMap( + ({ migrationsDirectory, entries }) => + entries + .filter( + (entry) => entry.isFile() && migrationFilenamePattern.test(entry.name), + ) + .map((entry) => ({ migrationsDirectory, filename: entry.name })), + ); + const duplicate = migrationEntries.find( + (entry, index) => + migrationEntries.findIndex((candidate) => candidate.filename === entry.filename) !== index, + ); + if (duplicate) { + throw new SafeMigrationError(`duplicate migration filename: ${duplicate.filename}`); + } + return Promise.all( - entries - .filter( - (entry) => entry.isFile() && migrationFilenamePattern.test(entry.name), - ) - .map((entry) => entry.name) - .sort() - .map(async (filename) => { + migrationEntries + .sort((left, right) => left.filename.localeCompare(right.filename)) + .map(async ({ migrationsDirectory, filename }) => { const bytes = await readFile(resolve(migrationsDirectory, filename)); return { filename, @@ -76,10 +98,11 @@ function assertLedgerFilesPresent(ledger, files) { export async function runMigrations({ connectionString, migrationsDirectory, + migrationsDirectories, logger = console, check = false, }) { - const files = await loadMigrationFiles(migrationsDirectory); + const files = await loadMigrationFiles(migrationsDirectories ?? migrationsDirectory); const client = new Client({ connectionString }); let locked = false; @@ -193,11 +216,21 @@ if (invokedPath === import.meta.url) { dirname(fileURLToPath(import.meta.url)), "../db/migrations", ); + const supabaseCompatibilityDirectory = resolve( + dirname(fileURLToPath(import.meta.url)), + "../supabase/migrations", + ); try { const status = await runMigrations({ connectionString: requireSchemaDatabaseUrl(process.env), - migrationsDirectory: - process.env.MIGRATIONS_DIRECTORY?.trim() || defaultDirectory, + ...(process.env.MIGRATIONS_DIRECTORY?.trim() + ? { migrationsDirectory: process.env.MIGRATIONS_DIRECTORY.trim() } + : { + migrationsDirectories: [ + defaultDirectory, + supabaseCompatibilityDirectory, + ], + }), check: process.argv.slice(2).includes("--check"), }); process.exitCode = status; diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index d87e2fd1..a5e09cd3 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -3,6 +3,8 @@ import { redirect } from "next/navigation"; import { isAdminEmail } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; +export const dynamic = "force-dynamic"; + export default async function AdminLayout({ children }: { children: ReactNode }) { if (process.env.NODE_ENV === "development" && process.env.ENABLE_ADMIN_PREVIEW === "1") return children; diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index 74c81471..d19598af 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -57,7 +57,7 @@ export async function GET() { // an older case. A concurrently created case simply appears on refresh. const { data: profile, error } = await supabase .from("profiles") - .select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset") + .select("credits,name,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_time,active_birth_time,birth_time_status,rectification_case_id") .eq("id", userId) .single(); @@ -72,6 +72,8 @@ export async function GET() { return NextResponse.json({ user: { id: user.id, email: user.email ?? null }, credits: profile.credits, + profile, + authProvider: process.env.AUTH_PROVIDER?.trim() === "self-hosted" ? "self-hosted" : "supabase", isAdmin: isAdminEmail(user.email), rectificationPriceCredits, hasConfirmedBirthTime: profile.birth_time_status === "confirmed" diff --git a/frontend/src/app/api/health/route.ts b/frontend/src/app/api/health/route.ts index 21e4ae97..ace662e0 100644 --- a/frontend/src/app/api/health/route.ts +++ b/frontend/src/app/api/health/route.ts @@ -64,10 +64,19 @@ export async function GET() { process.env.RECTIFICATION_V3_MIGRATIONS_READY?.trim().toLowerCase() === "true"; const creationPolicy = conversationalRectificationCreationPolicyFromEnvironment(); const truthSourceIdentity = getTruthSourceRuntimeIdentity(); + const selfHosted = process.env.AUTH_PROVIDER?.trim() === "self-hosted"; + const databaseChecks: Record = selfHosted + ? { + localBusinessDatabase: envCheck(["APP_DATABASE_URL", "ADMIN_DATABASE_URL"]), + localIdentityDatabase: envCheck(["IDENTITY_DATABASE_URL"]), + } + : { + supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]), + supabaseServiceRole: envCheck(["SUPABASE_SERVICE_ROLE_KEY"]), + }; const checks = { web: { status: "ok" } satisfies Check, - supabasePublicConfig: envCheck(["NEXT_PUBLIC_SUPABASE_URL", "NEXT_PUBLIC_SUPABASE_ANON_KEY"]), - supabaseServiceRole: envCheck(["SUPABASE_SERVICE_ROLE_KEY"]), + ...databaseChecks, modelProvider: anyEnvCheck(["LLM_MODELS_JSON", "OPENAI_API_KEY", "LLM_API_KEY", "DEEPSEEK_API_KEY"]), jyotishApi: await jyotishApiCheck(), researchTruthSource: { diff --git a/frontend/src/app/api/sessions/[id]/route.ts b/frontend/src/app/api/sessions/[id]/route.ts index a71ded96..afe1f665 100644 --- a/frontend/src/app/api/sessions/[id]/route.ts +++ b/frontend/src/app/api/sessions/[id]/route.ts @@ -1,21 +1,33 @@ import { NextResponse } from "next/server"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; -import { chatSessionWriteSchema } from "@/lib/chat-session-write-contract"; +import { + chatSessionModelPatchSchema, + chatSessionWriteSchema, +} from "@/lib/chat-session-write-contract"; type RouteContext = { params: Promise<{ id: string }> }; export async function PATCH(request: Request, context: RouteContext) { try { const { id } = await context.params; - const parsed = chatSessionWriteSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 }); + const payload = await request.json().catch(() => null); + const fullWrite = chatSessionWriteSchema.safeParse(payload); + const modelPatch = chatSessionModelPatchSchema.safeParse(payload); + let values: Record; + if (fullWrite.success) { + values = fullWrite.data; + } else if (modelPatch.success) { + values = modelPatch.data; + } else { + return NextResponse.json({ error: "聊天记录格式不正确" }, { status: 400 }); + } const supabase = await createServerSupabaseClient(); const { data: { user }, error: authError } = await supabase.auth.getUser(); if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); const { data, error } = await supabase .from("chat_sessions") - .update(parsed.data) + .update(values) .eq("id", id) .eq("user_id", user.id) .select("id") diff --git a/frontend/src/app/api/sessions/route.ts b/frontend/src/app/api/sessions/route.ts index 833b268a..8a548751 100644 --- a/frontend/src/app/api/sessions/route.ts +++ b/frontend/src/app/api/sessions/route.ts @@ -3,6 +3,26 @@ import { chatSessionCreateSchema } from "@/lib/chat-session-write-contract"; import { isSupabaseConfigurationError } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; +export async function GET() { + try { + const supabase = await createServerSupabaseClient(); + const { data: { user }, error: authError } = await supabase.auth.getUser(); + if (authError || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + const { data, error } = await supabase + .from("chat_sessions") + .select("id,title,theme,model_id,messages,session_type,rectification_case_id,updated_at") + .eq("user_id", user.id) + .order("updated_at", { ascending: false }); + if (error) return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 }); + return NextResponse.json({ sessions: data ?? [] }); + } catch (error) { + if (isSupabaseConfigurationError(error)) { + return NextResponse.json({ error: "数据库尚未配置", code: "DATABASE_NOT_CONFIGURED" }, { status: 503 }); + } + return NextResponse.json({ error: "聊天记录暂时无法读取" }, { status: 500 }); + } +} + export async function POST(request: Request) { try { const supabase = await createServerSupabaseClient(); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 8ddbd0ce..ee6bff5e 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -89,6 +89,7 @@ import { resolveSessionModelId, type PublicLanguageModelCatalog, } from "@/lib/public-models"; +import { selfHostedOtpActions } from "@/modules/identity/client"; import { createBrowserSupabaseClient } from "@/lib/supabase/client"; const BirthTimeRectification = dynamic( @@ -158,6 +159,8 @@ type BirthPlace = { label: string; lat: number; lon: number; tz: number }; type Account = { user: { id: string; email: string | null }; credits: number; + profile: unknown; + authProvider: "supabase" | "self-hosted"; isAdmin: boolean; rectificationPriceCredits: number; hasConfirmedBirthTime: boolean; @@ -738,6 +741,32 @@ async function fetchModelCatalog(signal?: AbortSignal) { return parsePublicModelCatalog(payload); } +async function fetchSessions(signal?: AbortSignal) { + const response = await fetch("/api/sessions", { signal, cache: "no-store" }); + if (response.status === 401) { + window.location.assign("/login"); + throw new Error("请先登录"); + } + const payload = await response.json().catch(() => null) as { sessions?: unknown; error?: string } | null; + if (!response.ok) throw new Error(payloadMessage(payload, "暂时无法读取聊天记录")); + return payload?.sessions ?? []; +} + +async function patchSessionModel(sessionId: string, modelId: string, signal?: AbortSignal) { + const response = await fetch(`/api/sessions/${encodeURIComponent(sessionId)}`, { + method: "PATCH", + headers: { "content-type": "application/json" }, + credentials: "same-origin", + signal, + body: JSON.stringify({ model_id: modelId }), + }); + const payload = await response.json().catch(() => null); + return { + found: response.ok, + error: response.ok ? null : payloadMessage(payload, "模型选择暂时无法同步"), + }; +} + export default function Home() { const [profile, setProfile] = useState(emptyProfile); const [profileDraft, setProfileDraft] = useState(emptyProfile); @@ -1075,6 +1104,8 @@ export default function Home() { setAccount({ user: { id: "preview-user", email: "preview@local.test" }, credits: 8, + profile: previewProfile, + authProvider: "self-hosted", isAdmin: false, rectificationPriceCredits: 1, hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed", @@ -1101,16 +1132,7 @@ export default function Home() { return; } - const supabase = createBrowserSupabaseClient(); - const { data: authData, error: authError } = await supabase.auth.getSession(); - if (authError) throw authError; - if (controller.signal.aborted) return; - if (!authData.session) { - window.location.assign("/login"); - return; - } - - const [nextAccount, modelCatalogResult] = await Promise.all([ + const [nextAccount, modelCatalogResult, sessionsPayload] = await Promise.all([ fetchAccount(controller.signal), fetchModelCatalog(controller.signal) .then((catalog) => ({ catalog, unavailable: false })) @@ -1118,50 +1140,30 @@ export default function Home() { if (caught instanceof Error && caught.name === "AbortError") throw caught; return { catalog: null, unavailable: true }; }), + fetchSessions(controller.signal), ]); const nextModelCatalog = modelCatalogResult.catalog; - const [profileResult, sessionsResult] = await Promise.all([ - supabase - .from("profiles") - .select("name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code") - .eq("id", nextAccount.user.id) - .abortSignal(controller.signal) - .maybeSingle(), - supabase - .from("chat_sessions") - .select("id,title,theme,model_id,messages,session_type,rectification_case_id,updated_at") - .abortSignal(controller.signal) - .order("updated_at", { ascending: false }), - ]); - - if (profileResult.error) throw profileResult.error; - if (sessionsResult.error) throw sessionsResult.error; - - const parsedSessions = readSessions(sessionsResult.data, nextModelCatalog); + const parsedSessions = readSessions(sessionsPayload, nextModelCatalog); let nextSessions = parsedSessions.sessions; if (nextSessions.length === 0) { if (controller.signal.aborted) return; const initialSession = createSession(nextModelCatalog?.defaultModelId ?? ""); - const { error } = await supabase - .from("chat_sessions") - .insert({ - id: initialSession.id, - user_id: nextAccount.user.id, + if (initialSession.modelId) { + await writeChatSession(initialSession.id, { title: initialSession.title, theme: initialSession.theme, - model_id: initialSession.modelId || null, + model_id: initialSession.modelId, messages: initialSession.messages, session_type: initialSession.sessionType, rectification_case_id: initialSession.rectificationCaseId, updated_at: new Date(initialSession.updatedAt).toISOString(), - }) - .abortSignal(controller.signal); - if (error) throw error; + }, "create"); + } nextSessions = [initialSession]; } if (controller.signal.aborted) return; - const nextProfile = readProfile(profileResult.data); + const nextProfile = readProfile(nextAccount.profile); setAccount(nextAccount); setModelCatalog(nextModelCatalog); setProfile(nextProfile); @@ -1178,13 +1180,9 @@ export default function Home() { setAccountError(""); if (nextModelCatalog && parsedSessions.fallbackSessionIds.length > 0) { - const { error } = await supabase - .from("chat_sessions") - .update({ model_id: nextModelCatalog.defaultModelId }) - .eq("user_id", nextAccount.user.id) - .in("id", parsedSessions.fallbackSessionIds) - .abortSignal(controller.signal); - if (error && !controller.signal.aborted) { + const results = await Promise.all(parsedSessions.fallbackSessionIds.map((sessionId) => + patchSessionModel(sessionId, nextModelCatalog.defaultModelId, controller.signal))); + if (results.some((result) => result.error) && !controller.signal.aborted) { setComposerNotice("已在当前页面切换为默认模型,但云端同步失败;刷新后可能需要重新选择。"); } } @@ -1491,14 +1489,8 @@ export default function Home() { if (process.env.NODE_ENV === "development" && uiPreview.current) { return { found: true, error: null }; } - const { data, error } = await createBrowserSupabaseClient() - .from("chat_sessions") - .update(values) - .eq("id", sessionId) - .eq("user_id", ownerId) - .select("id") - .maybeSingle(); - return { found: Boolean(data), error: error?.message ?? null }; + void ownerId; + return patchSessionModel(sessionId, values.model_id); }, userId, nextSession.id, @@ -1834,8 +1826,12 @@ export default function Home() { setSigningOut(true); setAccountError(""); try { - const { error } = await createBrowserSupabaseClient().auth.signOut(); - if (error) throw error; + if (account?.authProvider === "self-hosted") { + await selfHostedOtpActions.signOut(); + } else { + const { error } = await createBrowserSupabaseClient().auth.signOut(); + if (error) throw error; + } window.location.assign("/login"); } catch (caught) { const message = caught instanceof Error ? caught.message : "退出失败"; diff --git a/frontend/src/lib/chat-session-write-contract.ts b/frontend/src/lib/chat-session-write-contract.ts index fff156e0..a10df0c2 100644 --- a/frontend/src/lib/chat-session-write-contract.ts +++ b/frontend/src/lib/chat-session-write-contract.ts @@ -27,6 +27,10 @@ export const chatSessionCreateSchema = chatSessionWriteSchema.extend({ id: z.string().uuid(), }).strict(); +export const chatSessionModelPatchSchema = z.object({ + model_id: z.string().trim().min(1).max(64), +}).strict(); + export type ChatSessionWrite = Readonly<{ title: string; theme: "career" | "marriage" | "wealth" | "timing" | "general"; diff --git a/frontend/src/lib/db/local-postgres-client-core.ts b/frontend/src/lib/db/local-postgres-client-core.ts new file mode 100644 index 00000000..9b124e6c --- /dev/null +++ b/frontend/src/lib/db/local-postgres-client-core.ts @@ -0,0 +1,444 @@ +import { Pool, type PoolClient } from "pg"; + +type LocalIdentity = Readonly<{ id: string; email: string | null }> | null; +export type LocalDatabaseRole = "authenticated" | "service_role"; +type PostgresError = Error & { code?: string }; +type QueryError = Readonly<{ message: string; code?: string }>; +type QueryResult = Readonly<{ + data: unknown; + error: QueryError | null; + count?: number | null; +}>; + +type Filter = + | Readonly<{ kind: "eq"; column: string; value: unknown }> + | Readonly<{ kind: "in"; column: string; value: readonly unknown[] }> + | Readonly<{ kind: "is"; column: string; value: unknown }> + | Readonly<{ kind: "notContains"; column: string; value: unknown }>; + +type Mutation = + | Readonly<{ kind: "insert"; rows: readonly Record[] }> + | Readonly<{ kind: "update"; values: Record }> + | Readonly<{ kind: "upsert"; rows: readonly Record[]; conflict: readonly string[] }> + | Readonly<{ kind: "delete"; exactCount: boolean }>; + +const identifierPattern = /^[a-z_][a-z0-9_]*$/; + +function identifier(value: string): string { + const normalized = value.trim(); + if (!identifierPattern.test(normalized)) throw new Error("unsafe database identifier"); + return `"${normalized}"`; +} + +function queryError(error: unknown): QueryError { + const value = error as PostgresError; + return { + message: value instanceof Error ? value.message : "database request failed", + ...(typeof value?.code === "string" ? { code: value.code } : {}), + }; +} + +function records(value: Record | readonly Record[]) { + return Array.isArray(value) ? value : [value]; +} + +const poolGlobal = globalThis as typeof globalThis & { + jyotishaLocalDataPools?: Map; +}; + +function localDataPool(connectionString: string): Pool { + poolGlobal.jyotishaLocalDataPools ??= new Map(); + let pool = poolGlobal.jyotishaLocalDataPools.get(connectionString); + if (!pool) { + pool = new Pool({ + connectionString, + max: 10, + idleTimeoutMillis: 30_000, + connectionTimeoutMillis: 5_000, + allowExitOnIdle: true, + application_name: "jyotisha-business", + }); + poolGlobal.jyotishaLocalDataPools.set(connectionString, pool); + } + return pool; +} + +async function inBusinessTransaction( + pool: Pool, + identity: LocalIdentity, + role: LocalDatabaseRole, + run: (client: PoolClient) => Promise, +): Promise { + const client = await pool.connect(); + try { + await client.query("begin"); + await client.query(`set local role ${role}`); + await client.query( + "select set_config('request.jwt.claim.sub', $1, true), set_config('request.jwt.claim.email', $2, true)", + [identity?.id ?? "", identity?.email ?? ""], + ); + const result = await run(client); + await client.query("commit"); + return result; + } catch (error) { + await client.query("rollback").catch(() => undefined); + throw error; + } finally { + client.release(); + } +} + +async function columnTypes( + client: PoolClient, + table: string, +): Promise> { + const result = await client.query<{ column_name: string; udt_name: string }>( + ` + select column_name, udt_name + from information_schema.columns + where table_schema = 'public' and table_name = $1 + `, + [table], + ); + return new Map(result.rows.map((row) => [row.column_name, row.udt_name])); +} + +function databaseValue(type: string | undefined, value: unknown): unknown { + if ((type === "json" || type === "jsonb") && value !== null) { + return JSON.stringify(value); + } + return value; +} + +class LocalPostgresQueryBuilder implements PromiseLike { + private selectedColumns: string[] | null = null; + private mutation: Mutation | null = null; + private readonly filters: Filter[] = []; + private ordering: Readonly<{ column: string; ascending: boolean }> | null = null; + private rowLimit: number | null = null; + private abort: AbortSignal | null = null; + private cardinality: "many" | "single" | "maybeSingle" = "many"; + + constructor( + private readonly pool: Pool, + private readonly identity: LocalIdentity, + private readonly role: LocalDatabaseRole, + private readonly table: string, + ) { + identifier(table); + } + + select(columns = "*") { + this.selectedColumns = columns === "*" + ? ["*"] + : columns.split(",").map((column) => column.trim()).filter(Boolean); + for (const column of this.selectedColumns) { + if (column !== "*") identifier(column); + } + return this; + } + + insert(value: Record | readonly Record[]) { + this.mutation = { kind: "insert", rows: records(value) }; + return this; + } + + upsert( + value: Record | readonly Record[], + options: { onConflict: string }, + ) { + const conflict = options.onConflict.split(",").map((column) => column.trim()); + conflict.forEach(identifier); + this.mutation = { kind: "upsert", rows: records(value), conflict }; + return this; + } + + update(values: Record) { + this.mutation = { kind: "update", values }; + return this; + } + + delete(options?: { count?: string }) { + this.mutation = { kind: "delete", exactCount: options?.count === "exact" }; + return this; + } + + eq(column: string, value: unknown) { + identifier(column); + this.filters.push({ kind: "eq", column, value }); + return this; + } + + in(column: string, value: readonly unknown[]) { + identifier(column); + this.filters.push({ kind: "in", column, value }); + return this; + } + + is(column: string, value: unknown) { + identifier(column); + this.filters.push({ kind: "is", column, value }); + return this; + } + + not(column: string, operator: string, value: unknown) { + identifier(column); + if (operator !== "cs") throw new Error("unsupported not filter"); + this.filters.push({ kind: "notContains", column, value }); + return this; + } + + order(column: string, options: { ascending?: boolean } = {}) { + identifier(column); + this.ordering = { column, ascending: options.ascending !== false }; + return this; + } + + limit(value: number) { + if (!Number.isSafeInteger(value) || value < 0) throw new Error("invalid row limit"); + this.rowLimit = value; + return this; + } + + abortSignal(signal: AbortSignal) { + this.abort = signal; + return this; + } + + single() { + this.cardinality = "single"; + return this.execute(); + } + + maybeSingle() { + this.cardinality = "maybeSingle"; + return this.execute(); + } + + then( + onfulfilled?: ((value: QueryResult) => TResult1 | PromiseLike) | null, + onrejected?: ((reason: unknown) => TResult2 | PromiseLike) | null, + ): PromiseLike { + return this.execute().then(onfulfilled, onrejected); + } + + private returningClause(): string { + if (!this.selectedColumns) return ""; + return ` returning ${this.selectedColumns.map((column) => column === "*" ? "*" : identifier(column)).join(", ")}`; + } + + private filterClause(parameters: unknown[], types: Map): string { + if (this.filters.length === 0) return ""; + const parts = this.filters.map((filter) => { + const column = identifier(filter.column); + if (filter.kind === "is") { + if (filter.value === null) return `${column} is null`; + if (filter.value === true) return `${column} is true`; + if (filter.value === false) return `${column} is false`; + throw new Error("unsupported is filter"); + } + if (filter.kind === "in") { + if (filter.value.length === 0) return "false"; + const placeholders = filter.value.map((value) => { + parameters.push(databaseValue(types.get(filter.column), value)); + return `$${parameters.length}`; + }); + return `${column} in (${placeholders.join(", ")})`; + } + if (filter.kind === "notContains") { + parameters.push(databaseValue(types.get(filter.column), filter.value)); + return `not (${column} @> $${parameters.length})`; + } + parameters.push(databaseValue(types.get(filter.column), filter.value)); + return `${column} = $${parameters.length}`; + }); + return ` where ${parts.join(" and ")}`; + } + + private async execute(): Promise { + if (this.abort?.aborted) { + return { data: null, error: { message: "AbortError" } }; + } + try { + return await inBusinessTransaction(this.pool, this.identity, this.role, async (client) => { + const types = await columnTypes(client, this.table); + const parameters: unknown[] = []; + let sql: string; + + if (!this.mutation) { + const selected = (this.selectedColumns ?? ["*"]) + .map((column) => column === "*" ? "*" : identifier(column)) + .join(", "); + sql = `select ${selected} from public.${identifier(this.table)}`; + sql += this.filterClause(parameters, types); + if (this.ordering) { + sql += ` order by ${identifier(this.ordering.column)} ${this.ordering.ascending ? "asc" : "desc"}`; + } + if (this.rowLimit !== null) sql += ` limit ${this.rowLimit}`; + } else if (this.mutation.kind === "insert" || this.mutation.kind === "upsert") { + const rows = this.mutation.rows; + if (rows.length === 0) return { data: this.selectedColumns ? [] : null, error: null }; + const columns = Object.keys(rows[0] ?? {}); + if (columns.length === 0 || rows.some((row) => Object.keys(row).join("\0") !== columns.join("\0"))) { + throw new Error("inconsistent insert rows"); + } + columns.forEach(identifier); + const valueGroups = rows.map((row) => `(${columns.map((column) => { + parameters.push(databaseValue(types.get(column), row[column])); + return `$${parameters.length}`; + }).join(", ")})`); + sql = `insert into public.${identifier(this.table)} (${columns.map(identifier).join(", ")}) values ${valueGroups.join(", ")}`; + if (this.mutation.kind === "upsert") { + const updates = columns.filter((column) => !this.mutation || this.mutation.kind !== "upsert" || !this.mutation.conflict.includes(column)); + sql += ` on conflict (${this.mutation.conflict.map(identifier).join(", ")}) do ${updates.length === 0 + ? "nothing" + : `update set ${updates.map((column) => `${identifier(column)} = excluded.${identifier(column)}`).join(", ")}`}`; + } + sql += this.returningClause(); + } else if (this.mutation.kind === "update") { + const columns = Object.keys(this.mutation.values); + if (columns.length === 0) throw new Error("empty update"); + const assignments = columns.map((column) => { + identifier(column); + parameters.push(databaseValue(types.get(column), this.mutation && this.mutation.kind === "update" ? this.mutation.values[column] : null)); + return `${identifier(column)} = $${parameters.length}`; + }); + sql = `update public.${identifier(this.table)} set ${assignments.join(", ")}`; + sql += this.filterClause(parameters, types); + sql += this.returningClause(); + } else { + sql = `delete from public.${identifier(this.table)}`; + sql += this.filterClause(parameters, types); + sql += this.returningClause(); + } + + const result = await client.query(sql, parameters); + const rows = result.rows; + let data: unknown = this.selectedColumns ? rows : null; + if (this.cardinality !== "many") { + if (rows.length > 1 || (this.cardinality === "single" && rows.length !== 1)) { + return { data: null, error: { code: "PGRST116", message: "unexpected row count" } }; + } + data = rows[0] ?? null; + } + return { + data, + error: null, + ...(this.mutation?.kind === "delete" && this.mutation.exactCount + ? { count: result.rowCount ?? 0 } + : {}), + }; + }); + } catch (error) { + return { data: null, error: queryError(error), count: null }; + } + } +} + +type FunctionMetadata = Readonly<{ + proretset: boolean; + return_type: string; + argument_names: string[] | null; + argument_types: string[]; +}>; + +async function functionMetadata( + client: PoolClient, + functionName: string, + argumentNames: readonly string[], +): Promise { + const result = await client.query( + ` + select + p.proretset, + format_type(p.prorettype, null) as return_type, + p.proargnames as argument_names, + array( + select format_type(argument_type, null) + from unnest(p.proargtypes) argument_type + ) as argument_types + from pg_proc p + join pg_namespace n on n.oid = p.pronamespace + where n.nspname = 'public' + and p.proname = $1 + and $2::text[] <@ coalesce(p.proargnames, '{}'::text[]) + order by cardinality(p.proargtypes) asc + limit 1 + `, + [functionName, argumentNames], + ); + const metadata = result.rows[0]; + if (!metadata) throw new Error("database function not found"); + return metadata; +} + +function castType(value: string): string { + if (!/^[a-z0-9_ .\[\]]+$/.test(value)) throw new Error("unsafe database type"); + return value; +} + +export class LocalPostgresDataClient { + readonly auth: Readonly<{ + getUser: () => Promise>; + }>; + + private readonly pool: Pool; + + constructor( + connectionString: string, + private readonly identity: LocalIdentity, + private readonly role: LocalDatabaseRole, + ) { + if (role !== "authenticated" && role !== "service_role") { + throw new Error("unsupported database role"); + } + this.pool = localDataPool(connectionString); + this.auth = { + getUser: async () => ({ data: { user: this.identity }, error: null }), + }; + } + + from(table: string) { + return new LocalPostgresQueryBuilder(this.pool, this.identity, this.role, table); + } + + async rpc(functionName: string, args: Readonly> = {}) { + try { + identifier(functionName); + return await inBusinessTransaction(this.pool, this.identity, this.role, async (client) => { + const names = Object.keys(args); + names.forEach(identifier); + const metadata = await functionMetadata(client, functionName, names); + const typeByName = new Map( + (metadata.argument_names ?? []).map((name, index) => [name, metadata.argument_types[index]]), + ); + const parameters = names.map((name) => + databaseValue(typeByName.get(name), args[name])); + const call = names.map((name, index) => + `${identifier(name)} => $${index + 1}::${castType(typeByName.get(name) ?? "text")}`, + ).join(", "); + const sql = metadata.proretset + ? `select * from public.${identifier(functionName)}(${call})` + : `select public.${identifier(functionName)}(${call}) as value`; + const result = await client.query(sql, parameters); + return { + data: metadata.proretset ? result.rows : result.rows[0]?.value ?? null, + error: null, + }; + }); + } catch (error) { + return { data: null, error: queryError(error) }; + } + } +} + +export function createLocalPostgresDataClient( + connectionString: string, + identity: LocalIdentity = null, + role: LocalDatabaseRole = "authenticated", +) { + return new LocalPostgresDataClient(connectionString, identity, role); +} diff --git a/frontend/src/lib/db/local-postgres-client.ts b/frontend/src/lib/db/local-postgres-client.ts new file mode 100644 index 00000000..f2b2353f --- /dev/null +++ b/frontend/src/lib/db/local-postgres-client.ts @@ -0,0 +1,7 @@ +import "server-only"; + +export { + LocalPostgresDataClient, + createLocalPostgresDataClient, + type LocalDatabaseRole, +} from "./local-postgres-client-core"; diff --git a/frontend/src/lib/supabase/admin.ts b/frontend/src/lib/supabase/admin.ts index 9d725300..97c6d148 100644 --- a/frontend/src/lib/supabase/admin.ts +++ b/frontend/src/lib/supabase/admin.ts @@ -1,12 +1,21 @@ import "server-only"; -import { createClient } from "@supabase/supabase-js"; +import { createClient, type SupabaseClient } from "@supabase/supabase-js"; +import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client"; +import { readDatabaseUrl } from "@/lib/db/config"; import { getSupabaseUrl, SupabaseConfigurationError, } from "./config"; export function createAdminSupabaseClient() { + if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") { + return createLocalPostgresDataClient( + readDatabaseUrl(process.env, "ADMIN_DATABASE_URL"), + null, + "service_role", + ) as unknown as SupabaseClient; + } const url = getSupabaseUrl(); const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; if (!serviceRoleKey) { diff --git a/frontend/src/lib/supabase/server.ts b/frontend/src/lib/supabase/server.ts index fff76a73..fd7e9f21 100644 --- a/frontend/src/lib/supabase/server.ts +++ b/frontend/src/lib/supabase/server.ts @@ -1,10 +1,31 @@ import "server-only"; import { createServerClient } from "@supabase/ssr"; -import { cookies } from "next/headers"; +import type { SupabaseClient } from "@supabase/supabase-js"; +import { cookies, headers } from "next/headers"; +import { createLocalPostgresDataClient } from "@/lib/db/local-postgres-client"; +import { readDatabaseUrl } from "@/lib/db/config"; +import { getIdentityAuthServices } from "@/modules/identity/auth"; +import { readIdentitySession } from "@/modules/identity/session"; +import { readSelfHostedIdentityConfig } from "@/modules/identity/config"; +import { resolveIdentitySurface } from "@/modules/identity/host"; import { getSupabasePublicConfig } from "./config"; export async function createServerSupabaseClient() { + if (process.env.AUTH_PROVIDER?.trim() === "self-hosted") { + const requestHeaders = new Headers(await headers()); + const services = getIdentityAuthServices(); + const surface = resolveIdentitySurface( + requestHeaders.get("host"), + readSelfHostedIdentityConfig(process.env), + ); + const auth = surface === "admin" ? services.admin : services.user; + const session = await readIdentitySession(auth.api, requestHeaders); + return createLocalPostgresDataClient( + readDatabaseUrl(process.env, "APP_DATABASE_URL"), + session ? { id: session.user.id, email: session.user.email } : null, + ) as unknown as SupabaseClient; + } const { url, anonKey } = getSupabasePublicConfig(); const cookieStore = await cookies(); diff --git a/frontend/src/modules/identity/client.ts b/frontend/src/modules/identity/client.ts index a58d7d5e..15ec58b1 100644 --- a/frontend/src/modules/identity/client.ts +++ b/frontend/src/modules/identity/client.ts @@ -19,11 +19,13 @@ export interface SelfHostedOtpClient { otp: string; }): Promise; }; + signOut?(): Promise; } export interface SelfHostedOtpActions { send(email: string): Promise; verify(email: string, otp: string): Promise; + signOut(): Promise; } export function createSelfHostedOtpActions( @@ -48,6 +50,11 @@ export function createSelfHostedOtpActions( throw new Error("验证码错误或已过期,请重新获取"); } }, + async signOut() { + if (!client.signOut) throw new Error("退出失败,请稍后再试"); + const result = await client.signOut(); + if (result.error) throw new Error("退出失败,请稍后再试"); + }, }; } diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts new file mode 100644 index 00000000..757f125c --- /dev/null +++ b/frontend/tests/database-local-business.test.ts @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { createLocalPostgresDataClient } from "../src/lib/db/local-postgres-client-core.ts"; +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runnerPath = fileURLToPath( + new URL("../scripts/db-migrate.mjs", import.meta.url), +); + +test("local PostgreSQL applies the reviewed business schema and serves authenticated business calls", async () => { + const fixture = startPostgresFixture(); + const schemaUrl = fixture.connectionUrl( + "schema_owner", + "schema-owner-test-password", + ); + + try { + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { + ...process.env, + SCHEMA_DATABASE_URL: schemaUrl, + }, + }); + assert.equal(migration.status, 0, migration.stderr); + assert.match(migration.stdout, /applied 20260715000000_account_credits\.sql/); + assert.match(migration.stdout, /applied 20260721150000_align_conversational_finance_domain\.sql/); + + assert.equal( + fixture.psql(` + select string_agg(tablename, ',' order by tablename) + from pg_tables + where schemaname = 'public' + `), + [ + "birth_time_rectification_action_receipts", + "birth_time_rectification_billing", + "birth_time_rectification_cases", + "birth_time_rectification_dynamic_state", + "birth_time_rectification_event_evidence", + "birth_time_rectification_handoff_attach_receipts", + "birth_time_rectification_handoff_settlements", + "birth_time_rectification_question_handoffs", + "birth_time_rectification_scoring_jobs", + "birth_time_rectification_turns", + "chart_profiles", + "chat_sessions", + "consultation_requests", + "credit_request_cancellations", + "credit_transactions", + "profiles", + "redemption_codes", + "synastry_reports", + ].join(","), + ); + + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + ` + insert into identity.users (name, email, email_verified, email_verified_at) + values ('Local User', 'local-user@example.com', true, now()) + `, + ); + const userId = fixture.psql( + "select id from identity.users where email = 'local-user@example.com'", + ); + assert.equal( + fixture.psql(`select email from auth.users where id = '${userId}'`), + "local-user@example.com", + ); + assert.equal( + fixture.psql(`select email || ':' || credits from public.profiles where id = '${userId}'`), + "local-user@example.com:0", + ); + assert.equal( + fixture.psqlAs( + "app_runtime", + "app-runtime-test-password", + `set role authenticated; + select set_config('request.jwt.claim.sub', '${userId}', true); + select email from public.profiles where id = '${userId}'`, + ), + `SET\n${userId}\nlocal-user@example.com`, + ); + + const local = createLocalPostgresDataClient( + fixture.connectionUrl("app_runtime", "app-runtime-test-password"), + { id: userId, email: "local-user@example.com" }, + ); + const profile = await local.from("profiles") + .select("id,email,credits") + .eq("id", userId) + .single(); + assert.equal(profile.error, null); + assert.deepEqual(profile.data, { + id: userId, + email: "local-user@example.com", + credits: 0, + }); + const admin = createLocalPostgresDataClient( + fixture.connectionUrl("admin_runtime", "admin-runtime-test-password"), + null, + "service_role", + ); + const adminProfile = await admin.from("profiles") + .select("id") + .eq("id", userId) + .single(); + assert.equal(adminProfile.error, null); + assert.deepEqual(adminProfile.data, { id: userId }); + + const sessionId = "11111111-1111-4111-8111-111111111111"; + const inserted = await local.from("chat_sessions").insert({ + id: sessionId, + user_id: userId, + title: "Local conversation", + theme: "general", + model_id: "test-model", + messages: [], + session_type: "consultation", + rectification_case_id: null, + updated_at: new Date().toISOString(), + }).select("id").single(); + assert.equal(inserted.error, null); + assert.deepEqual(inserted.data, { id: sessionId }); + + fixture.psql(` + insert into public.redemption_codes (code_hash, code_mask, credits) + values ('${"a".repeat(64)}', 'JYOTISH-****-TEST', 3) + `); + const redeemed = await local.rpc("redeem_code", { + p_code_hash: "a".repeat(64), + }); + assert.equal(redeemed.error, null); + assert.deepEqual(redeemed.data, [{ success: true, credits: 3, error_code: null }]); + } finally { + fixture.stop(); + } +}); diff --git a/frontend/tests/database-topology.test.ts b/frontend/tests/database-topology.test.ts index 07f31500..8e4b4136 100644 --- a/frontend/tests/database-topology.test.ts +++ b/frontend/tests/database-topology.test.ts @@ -33,6 +33,14 @@ test("database roles have no cluster privileges", () => { "schema_owner:f:f:f:f:f:f", ].join("\n"), ); + assert.equal( + fixture.psql(` + select rolcanlogin || ':' || rolbypassrls + from pg_roles + where rolname = 'service_role' + `), + "f:true", + ); assert.equal( fixture.psql( "select nspowner::regrole::text from pg_namespace where nspname = 'public'", diff --git a/frontend/tests/health-deployment.test.ts b/frontend/tests/health-deployment.test.ts index ee0fff41..36a22a48 100644 --- a/frontend/tests/health-deployment.test.ts +++ b/frontend/tests/health-deployment.test.ts @@ -156,7 +156,7 @@ test("server compose defaults to local images without removing either build", () } }); -test("staging Caddy isolates the public and identity-only admin hosts", () => { +test("staging Caddy isolates the business admin surface from the public host", () => { const caddy = readFileSync( new URL("../../deploy/Caddyfile.staging", import.meta.url), "utf8", @@ -168,7 +168,9 @@ test("staging Caddy isolates the public and identity-only admin hosts", () => { /\{\$ADMIN_SITE_ADDRESS:https:\/\/admin\.staging\.jyotisha\.chat\}/, ); assert.match(caddy, /reverse_proxy web:3000/); - assert.match(caddy, /@identity path \/login \/api\/auth\/\*/); + assert.match(caddy, /@adminPaths path \/admin \/admin\/\* \/api\/admin\/\*/); + assert.match(caddy, /redir @adminRoot \/admin\/codes 302/); + assert.match(caddy, /@adminSurface path \/login \/admin \/admin\/\* \/api\/admin\/\* \/api\/auth\/\*/); assert.match(caddy, /respond "Not found" 404/); assert.doesNotMatch(caddy, /www\.jyotisha\.chat/); }); @@ -234,15 +236,19 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi "CADDYFILE_PATH=./Caddyfile.staging", "SITE_ADDRESS=https://staging.jyotisha.chat", "ADMIN_SITE_ADDRESS=https://admin.staging.jyotisha.chat", - "AUTH_PROVIDER=supabase", + "AUTH_PROVIDER=self-hosted", "SELF_HOSTED_IDENTITY_ENABLED=true", "AUTH_USER_ORIGIN=https://staging.jyotisha.chat", "AUTH_ADMIN_ORIGIN=https://admin.staging.jyotisha.chat", "IDENTITY_DATABASE_URL=postgresql://identity_runtime:identity-runtime-test-password@postgres:5432/jyotisha", + "APP_DATABASE_URL=postgresql://app_runtime:app-runtime-test-password@postgres:5432/jyotisha", + "ADMIN_DATABASE_URL=postgresql://admin_runtime:admin-runtime-test-password@postgres:5432/jyotisha", "BETTER_AUTH_USER_SECRET=user-secret-that-is-at-least-32-bytes-long", "BETTER_AUTH_ADMIN_SECRET=admin-secret-that-is-at-least-32-bytes-long", "RESEND_API_KEY=re_test_key_that_must_not_be_printed", "RESEND_FROM_EMAIL=Jyotisha Staging ", + "ADMIN_EMAILS=admin@example.com", + "JYOTISH_DYNAMIC_RECTIFICATION_TOKEN=dynamic-token-that-is-at-least-32-bytes", ]; const run = () => spawnSync("bash", [validator, envFile], { encoding: "utf8" }); @@ -325,7 +331,7 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi writeEnv( validSelectors.map((line) => line.startsWith("AUTH_PROVIDER=") - ? "AUTH_PROVIDER=self-hosted" + ? "AUTH_PROVIDER=supabase" : line, ), ); diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index 4e69f7e9..787d41c2 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -72,6 +72,7 @@ test("quality gate validates relevant changes once and publishes a digest manife assert.match(workflow, /node frontend\/scripts\/staging-image-manifest\.mjs/); assert.match(workflow, /name: staging-image-manifest-\$\{\{ github\.sha \}\}/); assert.match(workflow, /uses: actions\/upload-artifact@v4/); + assert.doesNotMatch(workflow, /STAGING_SUPABASE|NEXT_PUBLIC_SUPABASE/); assert.doesNotMatch(workflow, /(?:^|:)latest$/m); }); @@ -337,6 +338,11 @@ test("manual migration uses only PostgreSQL and the digest-pinned migrator", () assert.match(runner, /docker pull "\$WEB_IMAGE"/); assert.match(runner, /up -d --no-build --pull never --wait postgres/); assert.match(runner, /-f deploy\/docker-compose\.postgres\.yml/); + assertOrder(runner, [ + "up -d --no-build --pull never --wait postgres", + "002-ensure-business-compatibility-roles.sql", + "--profile migration run --rm migrator", + ]); assert.match(runner, /--profile migration run --rm migrator/); assert.match(runner, /select filename from migration\.schema_migrations order by filename/); assert.doesNotMatch(runner, /docker-compose\.server\.yml/);