-
Notifications
You must be signed in to change notification settings - Fork 0
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."
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
| 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).
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.
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.
Every domain row carries org_id. Helper SQL functions drive every policy:
-
my_org_id()— caller's org. -
is_admin()— true foradminandsuperadmin. -
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.
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
| 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). |
Aushang — Privacy-by-construction notice-board digitization · Repository · Built by Eugen Müller
Overview
Deep dives