Architecture — FinStack
Architecture — FinStack
Section titled “Architecture — FinStack”Last updated: 2026-06-01 (developer portal + OpenAPI)
Overview
Section titled “Overview”FinStack is an AI-native developer platform for financial services.
Three deployments in production; a greenfield Rust rewrite is in progress
and will replace the Workers/D1 stack at cutover. See CLAUDE.md §Active Rewrite.
Deployment Topology
Section titled “Deployment Topology”graph LR Browser["Browser / SDK consumer"]
subgraph CF_Pages["Cloudflare Pages"] Dashboard["finstack-dashboard.pages.dev\n(Next.js, edge runtime)"] DevPortal["finstack-developer-portal\n(Astro+Starlight, static)"] D1["D1: finstack-dashboard-auth\n(users, sessions, api_key_enc)"] end
subgraph Fly["Fly.io (iad)"] API["finstack-api.fly.dev\n(Rust, Axum)"] Neon["Neon Postgres (us-east-1)\nproject: muddy-union-15349058"] end
subgraph CF_Workers["Cloudflare Workers"] Marketing["finstack-marketing.finhub.workers.dev\n(Hono, dark-theme marketing site)"] end
subgraph External["External Services"] PostAI["post-ai.finhub.workers.dev\n(Post AI — managed Postgres-as-a-service)"] Stripe["api.stripe.com\n(Stripe — payment processing)"] end
Browser -->|"HTTPS developer docs"| DevPortal DevPortal -->|"GET /openapi.json (public, build-time)"| API Browser -->|"HTTPS auth / dashboard UI"| Dashboard Dashboard -->|"D1 binding AUTH_DB"| D1 Dashboard -->|"POST /admin/tenants/provision\n(X-Admin-Token, server-side only)"| API Dashboard -->|"GET /v1/observability\n(Bearer sk_live_…)"| API API -->|"sqlx connection pool"| Neon API -->|"Bearer POST_AI_API_KEY\n+ x-tenant-id header"| PostAI API -->|"POST /webhooks/stripe\n(Stripe-Signature HMAC-SHA256)"| API Stripe -->|"webhook delivery"| API Browser -->|"HTTPS marketing site"| MarketingComponent Map
Section titled “Component Map”| Component | Runtime | Repo path | Purpose |
|---|---|---|---|
finstack-dashboard | CF Pages (Next.js edge) | dashboard/ | Tenant dashboard — auth, API key management, usage, webhooks |
finstack-developer-portal | CF Pages (Astro+Starlight, static) | developer-portal/ | Public docs + internal team reference; API reference generated from /openapi.json at build time |
finstack-api | Fly.io container (Rust/Axum) | finstack-rs/ | Backend API — all primitives + admin endpoints |
finstack-marketing | CF Worker (Hono) | marketing-site/ | Public marketing site |
| Neon Postgres | Managed Postgres (us-east-1) | migrations in finstack-rs/migrations/ | Persistent store for finstack-api |
| D1 finstack-dashboard-auth | Cloudflare D1 (SQLite) | NextAuth adapter | Dashboard auth session store + encrypted API key cache |
Authentication Architecture
Section titled “Authentication Architecture”Two distinct auth systems — one per layer:
1. Dashboard User Auth (NextAuth v5)
Section titled “1. Dashboard User Auth (NextAuth v5)”- Providers: Magic Link, GitHub OAuth, Google OAuth
- Sessions stored in D1 (
sessionstable) AUTH_SECRET+AUTH_URLas CF Pages secrets- Edge runtime (
@cloudflare/next-on-pages) — no Node.js APIs
2. Dashboard → Backend API Key (per-user provisioning)
Section titled “2. Dashboard → Backend API Key (per-user provisioning)”On first authenticated dashboard request, the edge server-side component:
1. SELECT api_key_enc FROM users WHERE id = $userId (D1) ├─ Found → AES-GCM decrypt with FINSTACK_KEY_SEED → return key └─ Not found ↓
2. POST /admin/tenants/provision (finstack-api, X-Admin-Token) Body: { external_user_id, name, scopes } ├─ Upsert tenant ON CONFLICT (external_user_id) ├─ Mint key ON CONFLICT (tenant_id, external_user_id) DO NOTHING └─ Returns: { tenant_id, secret (first mint only), is_new_key }
3. AES-GCM encrypt secret with FINSTACK_KEY_SEED (32-byte hex) UPDATE users SET api_key_enc = $encrypted WHERE id = $userId (D1) Return raw key to page component
4. Page component calls /v1/observability (or other) with Bearer keyFINSTACK_ADMIN_TOKEN— CF Pages secret (server-side only, never in client bundle)FINSTACK_KEY_SEED— CF Pages secret (AES-GCM 32-byte hex seed)is_new_key=falseresponse means key already exists but secret was not returned → surface “reset API key” flow in settings- Scopes provisioned:
payments:read,payments:write,observability:read
3. Backend API Key Auth (finstack-rs)
Section titled “3. Backend API Key Auth (finstack-rs)”- Format:
sk_<env>_<tenant_short>_<random32> - Lookup prefix: first 12 chars of random component
- argon2id hash stored in
api_keys.key_hash - Scope checked per route via
ScopeGuardextractor
4. OAuth 2.0 / PKCE (finstack-rs, svc-auth)
Section titled “4. OAuth 2.0 / PKCE (finstack-rs, svc-auth)”Authorization code flow, S256 PKCE only. Third-party apps delegate on behalf of tenants.
1. Tenant registers client → POST /admin/oauth/clients → oauth_clients row2. App redirects user → GET /oauth/authorize (S256 code_challenge) Server issues single-use code (10-min TTL) → oauth_authorization_codes3. App exchanges → POST /oauth/token (code + code_verifier) Server: BASE64URL(SHA-256(verifier)) == stored challenge (constant-time) Issues at_<hex64> access token (1-hr TTL) → stored as SHA-256(raw)4. App calls → GET /v1/* with Authorization: Bearer at_<token> auth.rs: SHA-256(token) lookup in oauth_access_tokens (BYPASSRLS pool)Bearer token prefix ordering in api-rest/auth.rs:
INTERNAL_CALL_SECRET— intra-service bypass (constant-time compare, no DB)at_prefix — OAuth token path (BYPASSRLS pool, SHA-256 hash lookup)sk_prefix — API key path (argon2id verify)
Critical: OAuthService uses the BYPASSRLS worker_pool, not app_pool. Public endpoints (/oauth/token, /oauth/revoke) have no tenant context — FORCE ROW LEVEL SECURITY on oauth tables would make all rows invisible with finstack_app role.
Rust Backend Structure (finstack-rs/)
Section titled “Rust Backend Structure (finstack-rs/)”crates/ finstack-core — shared types, error envelopes, UUIDv7 finstack-db — sqlx pool factory, RLS helpers (with_tenant) finstack-auth — API key parse/hash/verify, scope definitions finstack-events — EventEnvelope, outbox writer, REQUEST_ID task-local finstack-tenancy — tenant CRUD finstack-webhooks — fan_out, sweep_pending, delivery audit finstack-observability — observability endpoint catalogue constant api-rest — Axum REST router (all /v1/* routes + middleware); POST|GET /mcp — MCP server (rmcp 1.7 streamable HTTP, Bearer auth) api-admin — Admin router (/admin/* routes, X-Admin-Token auth) api-graphql — async-graphql schema (mirrors REST surface) svc-auth — MFA, RBAC svc-database — tenant-facing managed Postgres (proxies to Post AI HTTP API) svc-payfac — sub-merchant lifecycle svc-compliance — KYC state machine svc-workflow — workflow runs + saga steps svc-composer — product composer primitive-{name} — one crate per primitive (payment, fraud, receipt, ...) note: primitive-payment depends on primitive-ledger (refund path posts reversing double-entry; see DATA_FLOWS.md) primitive-payment-stripe — Stripe payment adapter (StripeClient + StripeWebhookHandler) StripeClient wraps Stripe PaymentIntents API (create/capture/cancel/refund) StripeWebhookHandler validates HMAC-SHA256 signatures, deduplicates via stripe_webhook_events; uses BYPASSRLS worker pool (no tenant GUC at webhook time)bins/ finstack-api — main binary (merges all routers, runs migrations) finstack-worker — background worker: outbox dispatcher, webhook sweep, Stripe event handlerDatabase Schema (Postgres / Neon)
Section titled “Database Schema (Postgres / Neon)”Key tables (see finstack-rs/migrations/ for full DDL):
| Table | Purpose |
|---|---|
tenants | One row per API client; external_user_id links to NextAuth user |
api_keys | Hashed API keys; external_user_id for provisioning idempotency |
event_outbox | Transactional outbox — events written atomically with state changes |
event_outbox_dlq | Failed events after max_attempts |
webhook_endpoints | Subscriber URLs per tenant |
webhook_deliveries | Per-attempt delivery audit (trace_id denormalized) |
payments | Payment FSM rows; ledger_txn_id back-references the sale ledger entry (nullable — NULL until finstack-worker processes payment.captured and stamps it); stripe_payment_intent_id, stripe_processor_status when processor = 'stripe' |
refunds | Refund rows; ledger_txn_id back-references the reversing ledger entry (nullable — NULL until Phase 2 of the refund flow completes); stripe_refund_id when Stripe-routed |
stripe_webhook_events | Idempotency guard for inbound Stripe webhook events. Written via BYPASSRLS worker pool (no tenant context at signature time). processed_at = NULL until handler completes — enables retry on handler failure without duplicate processing. FORCE RLS. |
stripe_tenant_configs | Per-tenant Stripe keys (AES-256-GCM encrypted). Schema ready; per-tenant key rotation not yet wired to API. FORCE RLS. |
database_provisions | One row per tenant that has provisioned a managed Postgres DB via Post AI. Idempotent (ON CONFLICT DO NOTHING). RLS-enabled. |
oauth_clients | Registered OAuth clients per tenant (client_id, redirect_uris[]). FORCE RLS. |
oauth_authorization_codes | Single-use PKCE auth codes (10-min TTL, used_at set atomically on exchange). FORCE RLS. |
oauth_access_tokens | Issued access tokens stored as SHA-256 hash (1-hr TTL, revoked_at). FORCE RLS. Queried via BYPASSRLS pool. |
ledger_transactions | Ledger transaction headers (idempotency key, description) |
ledger_entries | Double-entry bookkeeping (debit/credit pairs per ledger_transaction_id) |
tenant_ledger_accounts | Per-tenant accounts map: semantic purpose (cash, revenue, …) → account_id |
Row-level security enforced via finstack_app role (NO BYPASSRLS).
Seeds and admin ops use finstack_worker role (BYPASSRLS).
Request Correlation
Section titled “Request Correlation”Every request gets a UUIDv7 X-Request-Id that propagates through:
api-rest middleware → event_outbox.trace_id → webhook_deliveries.trace_id → X-Finstack-Trace-Id header
See DATA_FLOWS.md §Request Correlation Chain for the full chain.
Key Design Decisions
Section titled “Key Design Decisions”See DECISIONS.md for ADRs. Notable ones:
- ADR-1: Derived
status_changed_at(no schema column; computed from per-transition timestamps at hydration) - External provisioning over static keys: per-user keys provisioned on first dashboard load — no manual key distribution, no shared secrets
- AES-GCM key caching in D1: avoids re-provisioning on every page load while keeping the raw key off the wire
References
Section titled “References”CLAUDE.md— active rewrite notes, integration test patterns, scope catalogueDATA_FLOWS.md— detailed data flow diagrams (trace_id chain, webhook gating, observability surface)DECISIONS.md— architectural decision recordsDEPLOYMENT.md— deploy procedures per environmentENVIRONMENTS.md— all secrets, URLs, and environment variables