Architecture (v2.1 — pre-license hybrid)
pnpm monorepo:
apps/web— Next.js 15 App Router, self-hosted on Hetzner (Docker +
Traefik) at https://app.stratifyinvest.com. Deploy via infra/hetzner/deploy-web-hetzner.sh. (Vercel is deactivated.)
apps/mobile— Expo SDK 52 onmain(SDK 56 on unmergedfeature/sdk-56).
iOS released via local fastlane (apps/mobile/scripts/testflight-local.sh), not EAS — see docs/runbooks/mobile-release.md.
packages/shared— mandates catalog, design tokens, zod schemas, formatters
Backend: Supabase EU, self-hosted on Hetzner (the /supabase Kong gateway for prod; Postgres + Auth + Storage + Realtime), accessed via @supabase/ssr (web) and @supabase/supabase-js (mobile). RLS enforces audience boundaries; service role is used only in server-only routes (B2B-API, webhooks, cron drainers). Clerk / Neon / S3 are dead.
Strategy mandate catalog and design system: docs/product-design.md, docs/stratify_design_system_v1.md. Catalog source of truth is packages/shared/src/mandates.ts — the count changes as mandates are added or retired (5 → 4 → 7 so far), so don't hardcode it; see the root `README.md` for how a catalog change propagates to every surface. Regulatory anchors: PRD-stratify-v2.md §8.
Component diagram
packages/shared is the thing that keeps the catalog dynamic in practice — it is the only place MandateId is defined, and every surface (web routes, mobile screens, the B2B API handlers, the MCP server) imports the type and the data from there instead of re-declaring it.
Route map (web)
app/page.tsx,app/walkthrough/[mandate]/page.tsx— public marketingapp/sign-in/,app/waitlist/— pre-launch funnelapp/(b2c)/...— authenticated subscriber surface (/feed,/account/...)app/(b2c)/account/push/actions.ts— register/revoke web-push subscriptionapp/api/v1/*— B2B-API (OpenAPI 3.1, source:openapi/stratify-api.yaml)app/api/webhooks/stripe,app/api/webhooks/supabase— inbound webhooksapp/api/cron/drain-{email,push,webhooks,expo-push}— outbox drainers
Pilot (app/(pilot)/pilot/...) and admin (app/(admin)/...) route groups are live — the 4-eyes approval queue and a ~25-route admin console (partners, compliance, audit, subscribers, billing, ops incidents, staff roles, platform health, …) shipped across roadmap buckets F and G. See `docs/v2.1-roadmap.md` for the build history and the root `README.md` for the by-surface feature list.
Route map (mobile)
app/index.tsx— landingapp/sign-in.tsx— 6-digit email-code auth (+ password fallback)app/feed.tsx— Supabase Realtime signal feed- (planned)
app/(b2c)/_layout.tsx— auth gate parity with web
Signal fan-out
apps/web/lib/signals/publish.ts is the single entry point. One call:
- INSERT
signalsrow. - INSERT
signal_deliveriesoutbox rows: one peremailrecipient, one per
in_app (auto-delivered — Realtime handles transport), one per web_push endpoint, one per expo_push token, one per live partner webhook URL.
recordAuditchain-hashed row inaudit_log.
Four Hetzner cron jobs drain the outbox (/etc/cron.d/stratify, installed via scripts/hetzner-install-crons.sh; source of truth infra/hetzner/cron-stratify.example). Each hits the ventures path on Hetzner with Authorization: Bearer $CRON_SECRET (not Vercel, not VERCEL_CRON_SECRET):
drain-email— AWS SES (SESv2Client) send + react-email templateemails/signal-broadcast.tsxdrain-push—web-pushlibrary + VAPID keys; auto-purges dead subscriptionsdrain-expo-push— Expo push tokens (expo_push_tokens) for the mobile appdrain-webhooks— outbound HMAC-SHA256-signed POST to partner URLs, 8-retry exponential back-off
Each drainer is idempotent at the row level (status flips pending → sent → delivered or retrying → failed).
Audit log
audit_log— append-only at the app + DB layer. Triggers
audit_log_no_update / audit_log_no_delete reject mutations.
- Each row:
hash = SHA-256( prev_hash || canonical(payload-row) ). apps/web/lib/audit/chain.ts—recordAudit(write),verifyChain(replay).- Tests:
apps/web/lib/audit/__tests__/chain.test.ts. - Retention 2y pre-license; flippable to 7y at FMA grant.
Data model (core tables)
Not exhaustive — see supabase/migrations/0001_init.sql onward for the full, current schema (23 migrations as of this writing). This is the spine that the signal fan-out and audit sections above operate on.
audit_log deliberately has no foreign key into signals or anything else — it's an append-only ledger of _every_ mutation across the app (pilot approvals, partner key rotation, corrections, not just signal publishes), so it's keyed only by its own hash chain.
Auth
Two separate auth models live side by side — human sign-in via Supabase Auth, machine access via a static bearer key. Neither surface can use the other's credential.
- B2C + Pilot + Admin (web + mobile) — Supabase Auth, 6-digit email OTP
(verifyOtp, type email) plus a password fallback. Emailed _links_ are not used: GoTrue drops the /supabase base path when building them, and nothing is routed at /auth/v1, so every emitted link 503s. See docs/adr/0002-email-code-auth-instead-of-links.md and the sign-in forms for detail. Web uses @supabase/ssr cookies; mobile uses SecureStore adapter.
- B2B-API — bearer tokens (
st_<prefix>_<secret>). Hash + prefix stored
in partner_api_keys; constant-time compare in lib/api/auth.ts. Scopes: signals:read, audit:read. Note: openapi/stratify-api.yaml's prose describes an OAuth2 Client Credentials exchange, but the implemented and actually-used model is this static bearer key — the spec text is aspirational, the bearerAuth security scheme it declares matches the real behavior.
Rate limits
Per-partner sliding window via api_request_log. Default 60/min, audit endpoint 30/min. See docs/internal/b2b-rate-limits.md.
Compliance posture (phase A)
Suitability + KYC + Stripe Connect re-enter at FMA license grant (Phase C). Today Stratify Signals is positioned as a research publication; all surfaces carry an explicit non-advice disclaimer. The 4-eyes pilot approval queue (pilot_approvals) is in the schema and waits for the admin surface.
DSGVO data flow: docs/compliance/dsgvo-data-flow.md. Audit export format: docs/compliance/audit-trail-export-format.md.