Skip to content

Architecture

eugnmueller-87 edited this page Jun 24, 2026 · 1 revision

Architecture

Aushang is two deployables joined by a shared-secret HTTP boundary: a Next.js 16 web app (Vercel) and a Python FastAPI worker (VPS, Docker). The database, auth, and storage are Supabase EU. The guiding idea is stated plainly in CLAUDE.md: "the security model is the architecture."

System shape

   Browser / Android shell (PWA)
   ───────────────────────────────
   anon Supabase client (RLS-governed)
            │
            ▼
   ┌─────────────────────────────────────────────────────────┐
   │  Next.js 16 (App Router) — Vercel                        │
   │                                                          │
   │  src/proxy.ts  ── deny-by-default middleware gate         │
   │       │                                                  │
   │       ▼                                                  │
   │  Server Components / Server Actions / Route Handlers      │
   │       │ (user)              │ (service role)             │
   │       ▼                     ▼                            │
   └───────┼─────────────────────┼────────────────────────────┘
           │ RLS-governed        │ BYPASSES RLS (definer RPCs only)
           ▼                     ▼
   ┌─────────────────────────────────────────────────────────┐
   │  Supabase (EU)  —  Postgres + Auth + Storage             │
   │  RLS ENABLE+FORCE on every table · security-definer fns  │
   │  buckets: raw-photos · redacted-photos · cover-photos    │
   └─────────────────────────────────────────────────────────┘

   capture: app → (X-Worker-Secret) → POST /process ─┐
                                                       ▼
   ┌─────────────────────────────────────────────────────────┐
   │  FastAPI worker — VPS / Docker (heavy ML stack)          │
   │  OpenCV → Tesseract → Presidio/spaCy/regex redact →      │
   │  LLM extract (redacted text only) → schema-validate      │
   └──────────────┬──────────────────────────────────────────┘
                  │ POST /api/worker/callback (X-Worker-Secret)
                  ▼  writes draft via service-role definer RPC
              Supabase

App vs worker — why split

Concern App (Vercel) Worker (VPS)
Runtime Next.js 16 / Node Python 3.11 / FastAPI / uvicorn
Holds the LLM key No (never) Yes (only place)
Sees raw PII No (server reads PII columns only for admin review) Yes (it produces the redaction)
ML stack None Tesseract, OpenCV, Presidio, spaCy de_core_news_lg (heavy → needs a VPS)
Optional Yes: without it, captures upload but stay processing; the rest of the app works

The worker exists so the heavy, privacy-critical processing happens off the client and the LLM key is isolated. The two talk only over WORKER_SHARED_SECRET (constant-time compare, both directions: app → /process, /translate; worker → /api/worker/callback, /api/worker/translation-callback).

The three Supabase clients — each has one job

From src/lib/supabase/. Never mix them.

File Key RLS Use
client.ts anon governed client components
server.ts anon + cookies governed server components/actions/handlers acting as the user
admin.ts service role bypasses only the security-definer RPCs, after our own checks
middleware.ts anon + cookies governed session refresh + getUser() in proxy.ts

admin.ts and env.server.ts both import "server-only" — the compile-time half of the secret boundary (build fails if pulled into a client component). The runtime half is scripts/check-no-client-secrets.mjs, which greps the built bundle in CI. A service-role client is never constructed outside admin.ts.

Authorization: four independent layers

Defense in depth — any single layer failing does not breach the system.

# Layer File(s) Job
1 Middleware src/proxy.ts, src/lib/routes.ts Deny-by-default coarse gate. Anything not on the routes.ts allowlist needs a validated session (getUser(), never getSession()). Fails closed on error.
2 Route guards src/lib/auth.ts requireSession() / requireAdmin() / requireSuperadmin() resolve user + DB-backed role at the top of every protected route. Authoritative; trusts no middleware header.
3 Security-definer RPCs migrations 0002, 0005, 0007, 0010 The only writers of profiles.role / orgs / drafts. search_path-pinned, input-validated, actor re-checked, auth.uid() backstop, service_role-only.
4 RLS + column grants migrations 0003, 0004, 0006 DB backstop. Org-scoped; members read only published/confirmed rows. PII columns are column-level REVOKEd (RLS gates rows, not columns).

See Security Model for the column-REVOKE detail and the adversarial-review history.

Three roles, multi-tenant by org_id

Every domain row carries org_id. Helper SQL functions drive every policy:

  • my_org_id() — caller's org.
  • is_admin() — true for admin and superadmin.
  • is_superadmin() — cross-org gate.

A superadmin's my_org_id() points at their own "Operator" anchor org, so the org-scoped *_admin_all policies never accidentally widen their reach — cross-org access comes only from dedicated *_superadmin_* policies.

Repository layout (key paths)

src/
  app/
    (app)/                 authenticated shell (requireSession)
      aufnahme/            admin capture (photograph → upload → trigger worker)
      review/              admin review gate (requireAdmin)
      feed/                the Pinnwand (all published posts)
      bereiche/            category hub + libraries + "new since last visit" counts
      essensplan/ rueckblick/ kalender/ info/ gesundheit/   per-category libraries
      einstellungen/       member settings (ICS, digest, push, photo-consent, delete)
      admin/mitglieder/    admin: members, groups, invites, consent overview
      operator/            superadmin: create orgs, manage admins
    api/worker/callback/           worker → app callback (shared-secret)
    api/worker/translation-callback/
    api/ics/[token]/               per-user ICS calendar feed
    apply/                         QR self-apply public surface
    auth/callback/                 invite/recovery landing + superadmin bootstrap
    login/ set-password/ passwort-vergessen/ registrieren/
    datenschutz/                   public legal page
  components/              "Tafel" design system, nav, category chips
  config/brand.ts          SINGLE source of branding
  lib/
    supabase/              client / server / admin / middleware
    content/               content_type taxonomy + extraction schema
    photo.ts               the single raw-vs-redacted signed-URL decision
    capture.ts             create upload target, trigger worker, trigger translation
    feed.ts ics.ts push.ts auth.ts auth-flows.ts calendar.ts
    routes.ts              public/admin/superadmin route allowlist
    env.ts env.server.ts   public vs server-only env (secret boundary)
  proxy.ts                 deny-by-default middleware
supabase/
  migrations/              0001 … 0030
  fixtures/two_orgs.sql    cross-tenant isolation test
  config.toml              version-controlled auth settings (enable_signup=false)
worker/
  src/aushang_worker/      app · pipeline · ocr · redaction · extraction · translation · cover · config · models
  Dockerfile               bakes Tesseract + de_core_news_lg
android/                   Capacitor native shell
scripts/                   setup wizard + secret-scan guards

What is intentionally NOT here

Item Why
LLM in the EU (default) Default extraction goes to Claude (US sub-processor), disclosed on /datenschutz. Swap the extraction provider to Mistral-EU for strict residency — nothing else changes.
Worker behind HTTPS Currently reachable over VPS HTTP; front with Caddy/Traefik + a worker. subdomain (follow-up).
Generated DB types src/lib/database.types.ts is a hand-authored stub; regenerate with supabase gen types later.
AI cover images (live) Built (worker/cover.py) but dormant until an EU FLUX.1 [schnell] endpoint is configured (fail-open).

Clone this wiki locally