Rendered from the repository — the file stays the source of truth.
Session B3 — Demo environment: hosted Supabase + full deployed E2E (2026-08-18)
Goal
Make the DEPLOYED product function end to end (feat/demo-env): hosted
Supabase behind the existing marginalia-webapp container, real runtime
env via Terraform, real build args via the deploy workflow, verified by a
full E2E on the public URL.
What was provisioned
- Supabase project
marginalia, refahphkkvsofqmxkqzbica, orgDonHeidi's Org(lvnsbuzfnbnigvuswtsl), region eu-west-3 (Paris) — chosen for colocation with the Scaleway fr-par container (~1 ms DB round-trips instead of cross-region). Created viasupabase projects create(CLI; the account token was created by the owner withsupabase login— it lives in the system keyring, not.env.local). - Tier: Free — owner decision (2026-08-18), presented against
feasibility’s pause-risk row: $0 but the project pauses after ~1 week
idle, and the demo URL dies until someone clicks Restore in the
dashboard. Operational rule recorded in
product/feasibility.md: check / restore (or upgrade to Pro for the window) before any demo. - DB password: generated locally (32 alnum chars), staged in
.env.localasSUPABASE_DB_PASSWORD, never echoed. TODO for owner: copy it (plus the newTF_VAR_supabase_*values) into themarginaliaProton Pass vault — they exist only in.env.localright now.
Migrations + schema verification (hosted)
supabase link + supabase db push applied all 7 migrations cleanly.
Verified by SQL against the hosted DB (transaction pooler
aws-1-eu-west-3.pooler.supabase.com:6543 — note aws-1, not aws-0):
- 8 tables in
public, all withrowsecurity = t; 1 policy per table- 6
storage.objectspolicies.
- 6
chunks_embedding_idx(HNSW) +chunks_fts_idx(GIN) present.- Buckets
sourcesandartifacts: private, 20 MB limit each. - pgvector 0.8.2 on Postgres 17.6 — identical to local (0.8.2 / PG 17): the feasibility “local vs hosted pgvector” risk row is resolved.
Auth config = code (supabase config push)
Hosted auth settings live in supabase/config.toml under
[remotes.demo] (project_id-keyed override section) and were applied
with supabase config push --project-ref ahphkkvsofqmxkqzbica. No
dashboard-only settings were needed — zero unrecorded clicks. Effective
hosted config:
- email+password signup on; email confirmation OFF (
mailer_autoconfirm) — hosted has no SMTP/mail catcher; Supabase’s built-in mailer is hard capped at 2 emails/hour anyway. site_url+ redirect allow-list = the container’s public endpoint.- Rate limits (SEC-7): 30 sign-in/sign-ups per 5 min per IP, 30 OTP verifications per 5 min, 150 token refreshes per 5 min; email resend frequency kept at hosted’s stricter 1/min, OTP length 8.
- CLI gotcha:
supabase config pushauto-confirms when it detects an agent — a piped “n” did not abort the first push. Treat everyconfig pushas an apply, not a preview.
Env-wiring design (build-time vs runtime split)
- Build-time (client bundle):
NEXT_PUBLIC_SUPABASE_URL/ANON_KEYare inlined bynext build→ they flow as Docker build args indeploy-webapp.ymlfrom GitHub secrets. B2’s placeholder secrets were replaced with the real hosted values viagh secret set(piped, no echo). The anon key is publishable by design (SEC-6): RLS enforces access, not key secrecy. - Runtime (container, via Terraform): plain env
NEXT_PUBLIC_SUPABASE_URL,NEXT_PUBLIC_SUPABASE_ANON_KEY,AZURE_SPEECH_REGION,TTS_PROVIDER,SCW_GENERATIVE_APIS_BASE_URL; secret envSUPABASE_SERVICE_ROLE_KEY,DATABASE_URL,AZURE_SPEECH_KEY,SCW_GENERATIVE_APIS_KEY. Values are sourced from.env.localasTF_VAR_*at apply time; nothing committed; no secrets in Terraform outputs. (They ARE in the Terraform state by nature ofsecret_environment_variables— recorded in SEC-6.) DATABASE_URLuses the transaction pooler (port 6543,postgres.<ref>@aws-1-eu-west-3.pooler.supabase.com); the app already setsprepare: false(apps/webapp/src/server/db/index.ts).webapp_min_scaletfvar (default 0 — owner decision): flip for demo windows withterraform apply -var webapp_min_scale=1(~€35/mo while 1).memory_limit_bytesaligned to the API’s stored 2147000000 — B2’s gotcha-5 drift is gone.
Deploy + E2E evidence (public URL)
Deploy: deploy-webapp dispatched on main after the secrets update — run
32139244122,
green in 3m07s, smoke test GET / -> HTTP 307 (auth proxy). Post-deploy
probe: GET / → 307 → /login, warm TTFB 0.21 s.
Full E2E driven headless (puppeteer-core + system chromium, scripts in the
session scratchpad) against
https://marginalia6bb21b06-marginalia-webapp.functions.fnc.fr-par.scw.cloud,
test users b3-e2e-user1/2@example.com:
| Step | Result | Timing |
|---|---|---|
| Signup (user 1) | account created, no email confirmation needed, session live, lands on library | 1.1 s |
| Create notebook | server action → redirect to /notebooks/414268fd-… |
1.1 s |
| Ingest PDF (47 KB test corpus, “Veldenbruck brief”) | client-side upload direct to hosted Storage (sources bucket, exercises inlined anon key + storage RLS) → parsed/chunked/embedded, status ready |
upload 0.7 s; ingest 1 s |
| Ingest URL (deployed docs site) | fetched, parsed, ready |
2 s |
| Grounded chat | correct facts from the PDF (48,215 inhabitants; founded 1362) with [1] citation chips resolving to the PDF chunk; streamed |
4.5 s to persisted answer |
| Citation click-through | SourceViewer opens, passage highlighted (mark[data-testid=cited-passage]), badge “Cited passage — Page 1” |
0.4 s |
| Save to note | button flips to “Saved to note”; note persists across reload in Studio panel | < 1 s |
| Audio Overview (English) | “Exploring Veldenbruck and the Marginalia Project”, 4:10 episode (250 s), duration_seconds=250 in DB; playback via signed URL from the hosted artifacts bucket, audio.duration 250.2 s in-browser |
15 s generation (LLM script + Azure TTS + upload) |
| Logged-out access | / and notebook URLs → 307 → /login (curl + browser) |
— |
| Foreign notebook | user 2 fetching user 1’s notebook → HTTP 404 | — |
DB cross-check (SQL on hosted): sources both ready (1 s / 2 s), 2 chunks,
artifact audio_overview/ready/250s generated in 15 s, 2 users.
E2E gotchas for whoever re-runs this: the Audio Overview dialog briefly
shows “Loading sources…” with Generate disabled — clicking too early is
a silent no-op (first attempt failed this way); wait for the source
checkboxes. React re-renders detach DOM nodes between query and click —
click inside page.evaluate, not via element handles.
Cost delta
- Supabase Free: $0 (pause trade-off accepted).
- Container: unchanged, min-scale 0 / max 2 → ~€0 idle.
- No new always-on resources. Demo mode remains an opt-in
-var webapp_min_scale=1(~€35/mo) + optional Pro upgrade ($25/mo).
Register/doc updates in this PR
product/feasibility.md: free-tier row → accepted w/ operational rule; pgvector-version row → resolved (0.8.2 both sides).product/security.md: SEC-6 key inventory (hosted service-role key, DB password, account token in keyring; secrets-in-tfstate note), SEC-7 hosted auth rate limits.product/architecture/physical.md: dated “now real” annotation + new Hosted Supabase (B3) section..env.schema: declarations forSUPABASE_ACCESS_TOKEN,SUPABASE_DB_PASSWORD,TF_VAR_supabase_*,TF_VAR_database_url,TF_VAR_azure_speech_key.
Not done / next
- Custom domains / Edge Services: not touched (time-allowing item; needs its own cost approval). Default endpoints serve the demo.
- Proton Pass: owner to store the new Supabase values (see above).
Done (2026-08-18, owner, recorded by foreman):
SUPABASE_DB_PASSWORDand theTF_VAR_supabase_*values are in themarginaliavault. - Before-demo checklist: restore/upgrade Supabase if paused; optionally
-var webapp_min_scale=1.