Merge pull request #27 from jesse-ux/codex/staging-local-postgres-cutover
feat: switch staging fully to local PostgreSQL
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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:<percent-encoded-identity-runtime-password>@postgres:5432/jyotisha
|
||||
APP_DATABASE_URL=postgresql://app_runtime:<percent-encoded-app-runtime-password>@postgres:5432/jyotisha
|
||||
ADMIN_DATABASE_URL=postgresql://admin_runtime:<percent-encoded-admin-runtime-password>@postgres:5432/jyotisha
|
||||
BETTER_AUTH_USER_SECRET=<independent-openssl-rand-base64-32-output>
|
||||
BETTER_AUTH_ADMIN_SECRET=<different-openssl-rand-base64-32-output>
|
||||
RESEND_API_KEY=<staging-only-resend-api-key>
|
||||
RESEND_FROM_EMAIL=Jyotisha Staging <login@staging.jyotisha.chat>
|
||||
ADMIN_EMAILS=<comma-separated-staging-admin-emails>
|
||||
JYOTISH_DYNAMIC_RECTIFICATION_TOKEN=<independent-openssl-rand-base64-32-output>
|
||||
|
||||
@@ -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
|
||||
|
||||
+6
-7
@@ -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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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<string, Check> = 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: {
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
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")
|
||||
|
||||
@@ -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();
|
||||
|
||||
+51
-55
@@ -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<Profile>(emptyProfile);
|
||||
const [profileDraft, setProfileDraft] = useState<Profile>(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 : "退出失败";
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string, unknown>[] }>
|
||||
| Readonly<{ kind: "update"; values: Record<string, unknown> }>
|
||||
| Readonly<{ kind: "upsert"; rows: readonly Record<string, unknown>[]; 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<string, unknown> | readonly Record<string, unknown>[]) {
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
const poolGlobal = globalThis as typeof globalThis & {
|
||||
jyotishaLocalDataPools?: Map<string, Pool>;
|
||||
};
|
||||
|
||||
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<T>(
|
||||
pool: Pool,
|
||||
identity: LocalIdentity,
|
||||
role: LocalDatabaseRole,
|
||||
run: (client: PoolClient) => Promise<T>,
|
||||
): Promise<T> {
|
||||
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<Map<string, string>> {
|
||||
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<QueryResult> {
|
||||
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<string, unknown> | readonly Record<string, unknown>[]) {
|
||||
this.mutation = { kind: "insert", rows: records(value) };
|
||||
return this;
|
||||
}
|
||||
|
||||
upsert(
|
||||
value: Record<string, unknown> | readonly Record<string, unknown>[],
|
||||
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<string, unknown>) {
|
||||
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<TResult1 = QueryResult, TResult2 = never>(
|
||||
onfulfilled?: ((value: QueryResult) => TResult1 | PromiseLike<TResult1>) | null,
|
||||
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
||||
): PromiseLike<TResult1 | TResult2> {
|
||||
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, string>): 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<QueryResult> {
|
||||
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<FunctionMetadata> {
|
||||
const result = await client.query<FunctionMetadata>(
|
||||
`
|
||||
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<Readonly<{
|
||||
data: { user: LocalIdentity };
|
||||
error: null;
|
||||
}>>;
|
||||
}>;
|
||||
|
||||
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<Record<string, unknown>> = {}) {
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import "server-only";
|
||||
|
||||
export {
|
||||
LocalPostgresDataClient,
|
||||
createLocalPostgresDataClient,
|
||||
type LocalDatabaseRole,
|
||||
} from "./local-postgres-client-core";
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -19,11 +19,13 @@ export interface SelfHostedOtpClient {
|
||||
otp: string;
|
||||
}): Promise<OtpClientResult>;
|
||||
};
|
||||
signOut?(): Promise<OtpClientResult>;
|
||||
}
|
||||
|
||||
export interface SelfHostedOtpActions {
|
||||
send(email: string): Promise<void>;
|
||||
verify(email: string, otp: string): Promise<void>;
|
||||
signOut(): Promise<void>;
|
||||
}
|
||||
|
||||
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("退出失败,请稍后再试");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -228,9 +228,6 @@ test("production first turn skips the minute-heavy candidate partition call for
|
||||
declaredBirthInput: {
|
||||
source: "unknown",
|
||||
birthDate: "1990-01-01",
|
||||
reportedTime: null,
|
||||
uncertaintyBeforeMinutes: null,
|
||||
uncertaintyAfterMinutes: null,
|
||||
birthTimeClue: null,
|
||||
birthplace: packetBirthplace,
|
||||
},
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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'",
|
||||
|
||||
@@ -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 <login@staging.jyotisha.chat>",
|
||||
"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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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/);
|
||||
|
||||
Reference in New Issue
Block a user