caterium-app/ops/supabase-selfhosted/frontend-cutover.md
pavlov346346 88b7f55db4 Ops: add self-hosted Supabase stack for Timeweb Cloud migration
Full production-ready docker-compose stack (db, kong, auth, rest,
realtime, storage, imgproxy, meta, functions, studio) targeting
api.caterium.ru, plus bootstrap script for a fresh Cloud Server,
Caddy reverse-proxy config (HTTPS, WebSocket, upload limits), and
dump/restore/verify/storage-sync scripts for moving off the managed
Supabase project (cksuehzcimitsxmeloes). Does not touch public/ or
any live runtime behavior — frontend cutover is documented separately
in frontend-cutover.md and only applied after Etap 8 verification.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-11 16:20:45 +03:00

5.4 KiB

Frontend cutover (Etap 9-11) — apply only after Etap 8 counts match

Two changes to public/app-runtime.js, both required together. Do not commit either until scripts/03-verify-counts.sql shows matching counts on old vs new (14/11/53/52/460/18) and the Etap 10 smoke test has passed against https://api.caterium.ru directly (before touching the frontend).

1. Point the default at the new backend

// public/app-runtime.js, line ~1989-1990
const DEFAULT_SUPABASE_URL = 'https://api.caterium.ru';
const DEFAULT_SUPABASE_KEY = '<NEW_ANON_KEY_FROM_.env>';

2. Auto-migrate devices that already have the OLD url cached in localStorage

Every Caterium device has sunCloudV2Config (and possibly the legacy sunEnterpriseSettingsV1) in localStorage, which loadConfig() reads before falling back to DEFAULT_SUPABASE_URL. Just changing the default does nothing for those devices — they keep talking to cksuehzcimitsxmeloes.supabase.co forever unless we rewrite the stored value once. Insert this right after the existing DEFAULT_SUPABASE_KEY line, and call it from inside loadConfig() before the return { ... }:

  const DEFAULT_SUPABASE_URL = 'https://api.caterium.ru';
  const DEFAULT_SUPABASE_KEY = '<NEW_ANON_KEY_FROM_.env>';
  const LEGACY_SUPABASE_URL = 'https://cksuehzcimitsxmeloes.supabase.co';

  function migrateLegacySupabaseUrl(value) {
    // One-time, silent migration: any device still pointed at the retired managed
    // Supabase project is switched to the self-hosted backend automatically. Users
    // are never asked to clear localStorage or reconfigure anything.
    if (String(value.url || '').trim().replace(/\/$/, '') === LEGACY_SUPABASE_URL) {
      value.url = DEFAULT_SUPABASE_URL;
      value.key = DEFAULT_SUPABASE_KEY;
    }
    return value;
  }

Then in loadConfig(), wrap the legacy-settings merge:

  function loadConfig() {
    let value = {};
    try { value = JSON.parse(localStorage.getItem(CONFIG_KEY) || '{}') || {}; } catch (_) {}
    if (!value.url || !value.key) {
      try {
        const legacy = JSON.parse(localStorage.getItem('sunEnterpriseSettingsV1') || '{}') || {};
        const cloud = legacy.cloud || {};
        value.url ||= String(cloud.supabaseUrl || '').trim();
        value.key ||= String(cloud.anonKey || '').trim();
      } catch (_) {}
    }
    value = migrateLegacySupabaseUrl(value);   // <-- add this line
    return {
      url: String(value.url || DEFAULT_SUPABASE_URL || '').trim().replace(/\/$/, ''),
      key: String(value.key || DEFAULT_SUPABASE_KEY || '').trim(),
      ...

saveConfig() (a few lines below) persists config back to localStorage on every config change already, so the corrected URL/key get written back to sunCloudV2Config the first time loadConfig() runs on each device — no explicit persistence call needed here, but if a save is not triggered naturally during your smoke test, call saveConfig() once right after config = loadConfig(); at module init (line ~1992) to force-persist the migration immediately rather than waiting for the next unrelated write.

3. Session invalidation is expected

Because the new stack uses a freshly generated JWT_SECRET (README decision, since the old one can't be safely retrieved), any cached Supabase auth session (JWT) in localStorage becomes invalid the moment the client points at the new URL — supabase-js will see a 401 on its first call, clear the stale session automatically, and the user lands back on the Caterium login screen. That matches the task's accepted trade-off: users log in once more with their existing email + password (unchanged, since auth.users.encrypted_password is copied byte-for-byte). No code change needed for this part — it's supabase-js's default behavior.

Checklist before running this cutover

  • scripts/03-verify-counts.sql matches old vs new (14/11/53/52/460/18)
  • curl https://api.caterium.ru/rest/v1/, /auth/v1/, /storage/v1/, /functions/v1/ all respond (not connection errors)
  • Realtime WebSocket connects: wscat -c wss://api.caterium.ru/realtime/v1/websocket?apikey=<ANON_KEY>
  • scripts/05-storage-sync.sh finished, spot-checked photo URLs load
  • scripts/04-deploy-edge-function.sh shows caterium-create-employee rejecting an unauthenticated call with 401/400 (not 5xx)
  • Manual login test against https://api.caterium.ru directly (temporarily point a local dev copy of app-runtime.js at it) with one real existing account, confirm orders/ clients/catalog load

Only once every box above is checked: apply the two edits above, commit to main with a message like Cutover: point Caterium at self-hosted Supabase (api.caterium.ru), push. CI (Caterium QA -> Caterium Promote Production) auto-promotes main to production on green, and the existing Timeweb cron (ops/timeweb/caterium-production-sync.sh) deploys it to app.caterium.ru within 5 minutes. Do NOT skip CI (--no-verify or manual production-branch pushes) for this change — the QA workflow's Playwright e2e run is exactly the safety net you want on a backend cutover.

After confirming https://app.caterium.ru works end-to-end with no supabase.co requests in DevTools Network, leave the old managed Supabase project (cksuehzcimitsxmeloes) running untouched as a cold backup, per the task's explicit instruction — do not pause or delete it without separate owner confirmation.