Skip to content

Architecture — FinStack

Last updated: 2026-06-01 (developer portal + OpenAPI)

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.

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"| Marketing
ComponentRuntimeRepo pathPurpose
finstack-dashboardCF Pages (Next.js edge)dashboard/Tenant dashboard — auth, API key management, usage, webhooks
finstack-developer-portalCF Pages (Astro+Starlight, static)developer-portal/Public docs + internal team reference; API reference generated from /openapi.json at build time
finstack-apiFly.io container (Rust/Axum)finstack-rs/Backend API — all primitives + admin endpoints
finstack-marketingCF Worker (Hono)marketing-site/Public marketing site
Neon PostgresManaged Postgres (us-east-1)migrations in finstack-rs/migrations/Persistent store for finstack-api
D1 finstack-dashboard-authCloudflare D1 (SQLite)NextAuth adapterDashboard auth session store + encrypted API key cache

Two distinct auth systems — one per layer:

  • Providers: Magic Link, GitHub OAuth, Google OAuth
  • Sessions stored in D1 (sessions table)
  • AUTH_SECRET + AUTH_URL as 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 key
  • FINSTACK_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=false response means key already exists but secret was not returned → surface “reset API key” flow in settings
  • Scopes provisioned: payments:read, payments:write, observability:read
  • 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 ScopeGuard extractor

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 row
2. App redirects user → GET /oauth/authorize (S256 code_challenge)
Server issues single-use code (10-min TTL) → oauth_authorization_codes
3. 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:

  1. INTERNAL_CALL_SECRET — intra-service bypass (constant-time compare, no DB)
  2. at_ prefix — OAuth token path (BYPASSRLS pool, SHA-256 hash lookup)
  3. 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.

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 handler

Key tables (see finstack-rs/migrations/ for full DDL):

TablePurpose
tenantsOne row per API client; external_user_id links to NextAuth user
api_keysHashed API keys; external_user_id for provisioning idempotency
event_outboxTransactional outbox — events written atomically with state changes
event_outbox_dlqFailed events after max_attempts
webhook_endpointsSubscriber URLs per tenant
webhook_deliveriesPer-attempt delivery audit (trace_id denormalized)
paymentsPayment 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'
refundsRefund 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_eventsIdempotency 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_configsPer-tenant Stripe keys (AES-256-GCM encrypted). Schema ready; per-tenant key rotation not yet wired to API. FORCE RLS.
database_provisionsOne row per tenant that has provisioned a managed Postgres DB via Post AI. Idempotent (ON CONFLICT DO NOTHING). RLS-enabled.
oauth_clientsRegistered OAuth clients per tenant (client_id, redirect_uris[]). FORCE RLS.
oauth_authorization_codesSingle-use PKCE auth codes (10-min TTL, used_at set atomically on exchange). FORCE RLS.
oauth_access_tokensIssued access tokens stored as SHA-256 hash (1-hr TTL, revoked_at). FORCE RLS. Queried via BYPASSRLS pool.
ledger_transactionsLedger transaction headers (idempotency key, description)
ledger_entriesDouble-entry bookkeeping (debit/credit pairs per ledger_transaction_id)
tenant_ledger_accountsPer-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).

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.

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
  • CLAUDE.md — active rewrite notes, integration test patterns, scope catalogue
  • DATA_FLOWS.md — detailed data flow diagrams (trace_id chain, webhook gating, observability surface)
  • DECISIONS.md — architectural decision records
  • DEPLOYMENT.md — deploy procedures per environment
  • ENVIRONMENTS.md — all secrets, URLs, and environment variables