Ver Fonte

[Fix] Realtime tenant seed + _realtime schema bootstrap (cold start)

Fix infra cause of cross-browser non-propagation: WS connects to the
self-hosted Realtime returned `TenantNotFound: supabase-realtime`. Two
gaps on cold start:

1. The `_realtime` schema (where Realtime stores tenant config + Ecto
   migrations) was missing. Realtime sets `search_path TO _realtime`
   before any migration runs and crashes with `no schema has been
   selected to create in`. New `supabase-realtime-schema-init` one-shot
   creates the schema before realtime starts.
2. `SEED_SELF_HOST=true` seeds external_id=`realtime-dev`, but Kong
   rewrites the upstream Host to `supabase-realtime:4000` so Realtime
   resolves the tenant slug as `supabase-realtime` — never matched. New
   `supabase-realtime-tenant-seed` one-shot POSTs `/api/tenants` after
   realtime is up, idempotent (GETs first, skips if present). Auths with
   a short-lived HS256 JWT signed with `JWT_SECRET` (= API_JWT_SECRET),
   role=`supabase_admin`.

Both services are `restart: "no"` and survive `docker compose down/up`
because the seeded rows live in the postgres data volume.

Verified: realtime logs clean (no TenantNotFound), WS handshake via
Kong returns 101 Switching Protocols, replication slot streaming.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User há 1 mês atrás
pai
commit
1a57f92f3e
4 ficheiros alterados com 185 adições e 0 exclusões
  1. 2 0
      CLAUDE.md
  2. 55 0
      docker-compose.yml
  3. 1 0
      research/PROJECT_INFO.md
  4. 127 0
      supabase/init/seed-realtime-tenant.sh

+ 2 - 0
CLAUDE.md

@@ -80,6 +80,8 @@ Tables: `users`, `groups`, `group_members`, `movies`, `landing_reel_posters`
 
 docker-compose orchestrates: Next.js app (node:22-slim, standalone, non-root, tini), self-hosted Supabase stack (Postgres, GoTrue, Realtime, PostgREST, Kong, Studio), Caddy (HTTPS, persistent volumes for /data and /config), Node.js cron container (node:22-alpine + node-cron for reel/trailer/metadata refresh), pg_dump backup container (daily, 7-day retention). Log rotation on all containers (max-size: 10m, max-file: 5). Disk encryption recommended on host (LUKS or cloud equivalent).
 
+**Realtime bootstrap (cold start):** `supabase-realtime-schema-init` creates `_realtime` schema before realtime starts (otherwise Ecto crashes: `no schema has been selected`). `supabase-realtime-tenant-seed` (runs `supabase/init/seed-realtime-tenant.sh`) POSTs `/api/tenants` to seed external_id=`supabase-realtime` (the slug Kong's upstream Host rewrites to — built-in `SEED_SELF_HOST` seeds the wrong slug `realtime-dev`). Both are idempotent and `restart: "no"`. Without these, all WS connects fail with `TenantNotFound` and no postgres_changes propagate.
+
 ## Env Vars
 
 ```

+ 55 - 0
docker-compose.yml

@@ -128,6 +128,30 @@ services:
     networks:
       - internal
 
+  # ─── Supabase: Realtime schema bootstrap ──────────────────────────
+  # Realtime's Ecto migrations target the `_realtime` schema and crash with
+  # `no schema has been selected to create in` if it doesn't exist yet
+  # (DB_AFTER_CONNECT_QUERY runs `SET search_path TO _realtime` before any
+  # migration). The supabase/postgres image's bundled migrations create
+  # `realtime` (WALRUS) but NOT `_realtime`. This one-shot creates it so
+  # supabase-realtime can boot cleanly on a fresh volume.
+  supabase-realtime-schema-init:
+    image: postgres:15-alpine
+    restart: "no"
+    logging: *default-logging
+    depends_on:
+      supabase-db:
+        condition: service_healthy
+    environment:
+      PGPASSWORD: ${POSTGRES_PASSWORD}
+    command: >
+      psql -h supabase-db -U ${POSTGRES_USER:-supabase_admin}
+      -d ${POSTGRES_DB:-supabase} -v ON_ERROR_STOP=1
+      -c "CREATE SCHEMA IF NOT EXISTS _realtime;"
+      -c "GRANT ALL ON SCHEMA _realtime TO ${POSTGRES_USER:-supabase_admin};"
+    networks:
+      - internal
+
   # ─── Supabase: Realtime ───────────────────────────────────────────
   supabase-realtime:
     image: supabase/realtime:v2.34.47
@@ -136,6 +160,8 @@ services:
     depends_on:
       supabase-db:
         condition: service_healthy
+      supabase-realtime-schema-init:
+        condition: service_completed_successfully
     environment:
       PORT: "4000"
       DB_HOST: supabase-db
@@ -155,6 +181,35 @@ services:
     networks:
       - internal
 
+  # ─── Supabase: Realtime tenant seed ───────────────────────────────
+  # Realtime is multi-tenant. Reaching it via Kong rewrites the upstream
+  # Host to `supabase-realtime:4000`, which it maps to external_id
+  # `supabase-realtime`. The built-in SEED_SELF_HOST seeds `realtime-dev`
+  # (wrong slug) and never creates `supabase-realtime`. This one-shot
+  # POSTs /api/tenants to create the row. Idempotent (GET first; skip if
+  # present). The tenant row lives in the `_realtime.tenants` table inside
+  # the postgres data volume, so this only does real work on a cold start.
+  supabase-realtime-tenant-seed:
+    image: alpine:3.20
+    restart: "no"
+    logging: *default-logging
+    depends_on:
+      supabase-realtime:
+        condition: service_started
+    environment:
+      JWT_SECRET: ${JWT_SECRET}
+      POSTGRES_USER: ${POSTGRES_USER:-supabase_admin}
+      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
+      POSTGRES_DB: ${POSTGRES_DB:-supabase}
+    volumes:
+      - ./supabase/init/seed-realtime-tenant.sh:/seed-realtime-tenant.sh:ro
+    entrypoint: ["/bin/sh", "-c"]
+    command: >
+      "apk add --no-cache curl postgresql-client openssl >/dev/null
+      && /seed-realtime-tenant.sh"
+    networks:
+      - internal
+
   # ─── Supabase: Kong (API Gateway) ─────────────────────────────────
   supabase-kong:
     image: kong:2.8.1

+ 1 - 0
research/PROJECT_INFO.md

@@ -49,3 +49,4 @@ MovieDice is a mobile-first web app that helps friend groups collaboratively bui
 | 2026-04-05 | Second security review -- updated architecture analysis        | ./research/SECFILE.md (Second Review)                            |
 | 2026-04-05 | Second compliance review -- updated scope (15 new findings)    | ./research/COMPLIANCE.md (Second Review)                         |
 | 2026-05-07 | programmer                                                     | Realtime UPDATE (3.8), offline search (5.6), pre-push hook (1.1) |
+| 2026-05-08 | programmer                                                     | Realtime tenant seed + \_realtime schema bootstrap (cold-start)  |

+ 127 - 0
supabase/init/seed-realtime-tenant.sh

@@ -0,0 +1,127 @@
+#!/bin/sh
+# seed-realtime-tenant.sh
+#
+# One-shot initializer for the self-hosted Supabase Realtime tenant.
+#
+# Realtime v2.x is multi-tenant. Each "project" needs a row in
+# `_realtime.tenants` (with a paired `_realtime.extensions` row for
+# postgres_cdc_rls). When the realtime container is reached via Kong, the
+# upstream Host header becomes `supabase-realtime:4000`, which Realtime maps
+# to external_id=`supabase-realtime`. Without a matching tenant row, every WS
+# connect fails with `TenantNotFound` and no postgres_changes propagate.
+#
+# Despite SEED_SELF_HOST=true, the auto-seed inserts external_id=`realtime-dev`
+# (wrong slug for our setup) and never creates `supabase-realtime`. This
+# script is the deterministic fix.
+#
+# Steps:
+#   1. Ensure `_realtime` schema exists (Realtime's Ecto migrations target it
+#      and bail with `no schema has been selected` if absent).
+#   2. Wait for the realtime HTTP API to come up.
+#   3. If a tenant with external_id=`supabase-realtime` already exists, exit 0.
+#   4. Otherwise mint a short-lived HS256 JWT signed with $JWT_SECRET (which
+#      Realtime accepts as API_JWT_SECRET) and POST /api/tenants.
+#
+# Idempotent: safe to run on every `docker compose up`. Runs to completion
+# and exits; the container is `restart: "no"`.
+#
+# Required env: JWT_SECRET, POSTGRES_PASSWORD, (optionals with defaults)
+# POSTGRES_USER, POSTGRES_DB, DB_HOST, REALTIME_HOST, REALTIME_PORT.
+
+set -eu
+
+POSTGRES_USER="${POSTGRES_USER:-supabase_admin}"
+POSTGRES_DB="${POSTGRES_DB:-supabase}"
+DB_HOST="${DB_HOST:-supabase-db}"
+DB_PORT="${DB_PORT:-5432}"
+REALTIME_HOST="${REALTIME_HOST:-supabase-realtime}"
+REALTIME_PORT="${REALTIME_PORT:-4000}"
+TENANT_EXTERNAL_ID="${TENANT_EXTERNAL_ID:-supabase-realtime}"
+
+: "${JWT_SECRET:?JWT_SECRET must be set}"
+: "${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}"
+
+log() { printf '[seed-realtime] %s\n' "$*"; }
+
+# --- 1. Ensure _realtime schema exists ----------------------------------
+log "ensuring _realtime schema exists on ${DB_HOST}:${DB_PORT}/${POSTGRES_DB}"
+PGPASSWORD="$POSTGRES_PASSWORD" psql \
+  -h "$DB_HOST" -p "$DB_PORT" -U "$POSTGRES_USER" -d "$POSTGRES_DB" \
+  -v ON_ERROR_STOP=1 \
+  -c "CREATE SCHEMA IF NOT EXISTS _realtime;" \
+  -c "GRANT ALL ON SCHEMA _realtime TO ${POSTGRES_USER};" >/dev/null
+
+# --- 2. Wait for realtime API ------------------------------------------
+log "waiting for realtime API at http://${REALTIME_HOST}:${REALTIME_PORT}"
+i=0
+until curl -sS -o /dev/null -w '%{http_code}' "http://${REALTIME_HOST}:${REALTIME_PORT}/api/health" 2>/dev/null | grep -qE '^(200|401|403|404)$'; do
+  i=$((i + 1))
+  if [ "$i" -gt 60 ]; then
+    log "realtime API did not become reachable within 60s; aborting"
+    exit 1
+  fi
+  sleep 1
+done
+log "realtime API reachable"
+
+# --- 3. Mint short-lived API JWT (HS256) -------------------------------
+# Header/payload base64url, HMAC-SHA256 signature with $JWT_SECRET.
+b64url() { openssl base64 -A | tr '+/' '-_' | tr -d '='; }
+NOW=$(date -u +%s)
+EXP=$((NOW + 300))
+HEADER='{"alg":"HS256","typ":"JWT"}'
+PAYLOAD="{\"iss\":\"supabase\",\"role\":\"supabase_admin\",\"iat\":${NOW},\"exp\":${EXP}}"
+H_B64=$(printf '%s' "$HEADER" | b64url)
+P_B64=$(printf '%s' "$PAYLOAD" | b64url)
+SIG=$(printf '%s' "${H_B64}.${P_B64}" \
+  | openssl dgst -sha256 -hmac "$JWT_SECRET" -binary \
+  | b64url)
+JWT="${H_B64}.${P_B64}.${SIG}"
+
+# --- 4. Check existing tenant; create if absent ------------------------
+TENANT_URL="http://${REALTIME_HOST}:${REALTIME_PORT}/api/tenants/${TENANT_EXTERNAL_ID}"
+HTTP=$(curl -sS -o /tmp/tenant_get.json -w '%{http_code}' \
+  -H "Authorization: Bearer ${JWT}" \
+  "$TENANT_URL" || echo "000")
+if [ "$HTTP" = "200" ]; then
+  log "tenant ${TENANT_EXTERNAL_ID} already exists; nothing to do"
+  exit 0
+fi
+log "tenant ${TENANT_EXTERNAL_ID} not found (GET=${HTTP}); creating"
+
+BODY=$(cat <<JSON
+{
+  "tenant": {
+    "name": "${TENANT_EXTERNAL_ID}",
+    "external_id": "${TENANT_EXTERNAL_ID}",
+    "jwt_secret": "${JWT_SECRET}",
+    "extensions": [{
+      "type": "postgres_cdc_rls",
+      "settings": {
+        "db_host": "${DB_HOST}",
+        "db_name": "${POSTGRES_DB}",
+        "db_user": "${POSTGRES_USER}",
+        "db_password": "${POSTGRES_PASSWORD}",
+        "db_port": "${DB_PORT}",
+        "region": "us-east-1",
+        "ssl_enforced": false,
+        "publication": "supabase_realtime"
+      }
+    }],
+    "postgres_cdc_default": "postgres_cdc_rls"
+  }
+}
+JSON
+)
+
+HTTP=$(curl -sS -o /tmp/tenant_post.json -w '%{http_code}' \
+  -X POST "http://${REALTIME_HOST}:${REALTIME_PORT}/api/tenants" \
+  -H "Authorization: Bearer ${JWT}" \
+  -H "Content-Type: application/json" \
+  -d "$BODY")
+if [ "$HTTP" != "201" ] && [ "$HTTP" != "200" ]; then
+  log "tenant creation failed (HTTP ${HTTP}):"
+  cat /tmp/tenant_post.json || true
+  exit 1
+fi
+log "tenant ${TENANT_EXTERNAL_ID} created (HTTP ${HTTP})"