diff --git a/docs/operations/production-server-migration-2026-08.md b/docs/operations/production-server-migration-2026-08.md index 53264032..d15374a5 100644 --- a/docs/operations/production-server-migration-2026-08.md +++ b/docs/operations/production-server-migration-2026-08.md @@ -11,6 +11,16 @@ This runbook moves production to the current reviewed `staging` release while al This is not a volume copy. A full Supabase dump must not be restored over the target database. +## Confirmed migration decisions + +- production Owner: `luna@copse.life` / `b8907d0c-6ed0-4270-b866-7e83bb4a1b26`; +- source Supabase project: `vtvnfqmonbfuxmqkqdlc`; +- the legacy production `ADMIN_EMAILS` allowlist contains only the designated Owner, and the legacy database has no `public.admin_users`; the ETL seeds that attested Owner into the target admin model; +- payment and model-provider ciphertext mode: `exclude`; re-enter both configurations after cutover with newly generated encryption keys; +- release policy: validate on `staging`, then promote the same accepted SHA to production; +- DNS remains unchanged until the final ETL and reconciliation pass; +- proposed maintenance window: `2026-08-12 02:00–04:00 UTC+8`, pending operator confirmation after rehearsal timing. + ## Release invariants The production deployment and schema-migration workflows are manual-only and accept a full lowercase 40-character `deploy_sha`. A normal production mutation proceeds only when all of the following identify that exact SHA: @@ -30,7 +40,7 @@ Repository variables: | Name | Required value | | --- | --- | | `PRODUCTION_HOST` | `118.194.235.34` | -| `PRODUCTION_PORT` | Confirmed SSH port; do not assume `22` | +| `PRODUCTION_PORT` | `22` (confirmed on 2026-08-09) | | `PRODUCTION_USER` | `deploy` | | `PRODUCTION_PATH` | `/opt/jyotisha-production` | | `PRODUCTION_URL` | `https://jyotisha.chat` | diff --git a/frontend/scripts/migrate-supabase-production.mjs b/frontend/scripts/migrate-supabase-production.mjs index 01f6ce73..293d9e18 100644 --- a/frontend/scripts/migrate-supabase-production.mjs +++ b/frontend/scripts/migrate-supabase-production.mjs @@ -392,10 +392,9 @@ async function readSourceUsers(source, sourceAuthSchema, activeAdminUserIds) { ); } -async function readActiveAdminUserIds(source, sourceTables) { - if (!sourceTables.has("admin_users")) { - throw new SafeProductionMigrationError("source public.admin_users is missing"); - } +export async function readActiveAdminUserIds(source, sourceTables, ownerUserId) { + // Legacy production used a single ADMIN_EMAILS allowlist and has no admin tables. + if (!sourceTables.has("admin_users")) return new Set([ownerUserId]); const result = await source.query( "select user_id from public.admin_users where revoked_at is null order by user_id", ); @@ -442,6 +441,7 @@ async function assertNoUnmappedSourceTables(source, sourceTables, targetTables) } async function assertActiveAdminRoles(source, sourceTables, ownerUserId) { + if (!sourceTables.has("admin_users")) return; if (!sourceTables.has("admin_user_roles") || !sourceTables.has("admin_roles")) { const result = await source.query( `select count(*)::bigint as count from public.admin_users where revoked_at is null and user_id <> $1`, @@ -715,7 +715,7 @@ async function preflightContext(source, target, config, { requireEmpty = true } readSchema(source, "auth"), readSchema(target, "public"), ]); - const activeAdminUserIds = await readActiveAdminUserIds(source, sourcePublic); + const activeAdminUserIds = await readActiveAdminUserIds(source, sourcePublic, config.ownerUserId); const users = await readSourceUsers(source, sourceAuth, activeAdminUserIds); assertActiveAdminUsers(users, activeAdminUserIds, config.ownerUserId); if (requireEmpty) await assertTargetEmpty(target, targetPublic); diff --git a/frontend/tests/production-data-migration.test.ts b/frontend/tests/production-data-migration.test.ts index 4c43392c..840a0b42 100644 --- a/frontend/tests/production-data-migration.test.ts +++ b/frontend/tests/production-data-migration.test.ts @@ -10,6 +10,7 @@ import { normalizeAuthUser, normalizeAuthUsers, parseMode, + readActiveAdminUserIds, readConfiguration, readSchema, rowsSha256, @@ -126,6 +127,20 @@ test("production identity preflight rejects duplicate canonical emails", () => { ); }); +test("legacy production without admin tables promotes only the designated Owner", async () => { + const ownerId = "018f4e6d-7a11-7000-8000-000000000001"; + const source = { + async query() { + throw new Error("legacy fallback must not query a missing admin_users table"); + }, + }; + + assert.deepEqual( + [...await readActiveAdminUserIds(source, new Map(), ownerId)], + [ownerId], + ); +}); + test("active administrators become usable identity admins", () => { const ownerId = "018f4e6d-7a11-7000-8000-000000000001"; const adminId = "018f4e6d-7a11-7000-8000-000000000002";