Merge pull request #27 from jesse-ux/codex/staging-local-postgres-cutover

feat: switch staging fully to local PostgreSQL
This commit is contained in:
jesse-ux
2026-07-22 11:36:54 +08:00
committed by GitHub
31 changed files with 1011 additions and 144 deletions
@@ -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:
+7 -3
View File
@@ -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>
+9 -2
View File
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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}
+11
View File
@@ -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;
+1
View File
@@ -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
+2
View File
@@ -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'
+21 -1
View File
@@ -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"
+14 -12
View File
@@ -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;
+55 -22
View File
@@ -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;
+2
View File
@@ -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;
+3 -1
View File
@@ -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"
+11 -2
View File
@@ -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: {
+16 -4
View File
@@ -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")
+20
View File
@@ -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
View File
@@ -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";
+10 -1
View File
@@ -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) {
+22 -1
View File
@@ -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();
+7
View File
@@ -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();
}
});
+8
View File
@@ -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'",
+10 -4
View File
@@ -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/);