# Architecture

High-level map of how Finisit Site is wired together. Read this first if you're contributing or reviewing changes.

## Stack

| Layer | Choice | Why |
|---|---|---|
| Hosting | Vercel (serverless) | Static + edge functions in one platform; free tier viable for MVP |
| DB | Neon Postgres (`@neondatabase/serverless`) | HTTP-based driver — no TCP pool issues from serverless cold starts |
| Auth | JWT in HttpOnly cookies + bcrypt | No external auth service; user + admin sessions kept separate |
| Payments | Stripe Checkout + webhooks | Best-in-class; we never touch raw card data |
| Fulfillment | Printful (server-side price + order create) | Print-on-demand; price tampering blocked at API layer |
| Email | Resend | Transactional; no SDK — direct HTTP |
| Tests | Vitest | Fast, ESM-friendly, no bundler needed |

## Repository layout

```
api/                  — Vercel serverless functions (12/12 used; Hobby limit)
  _lib/               — shared modules (DON'T count against function limit)
    admin-auth.js     — RBAC + JWT + CSRF + permission matrix
    admin-emails.js   — Resend HTML templates (invite / reset / digest)
    audit.js          — append-only audit_log writer
    cors.js           — origin whitelist + preflight handling
    db.js             — getDb() wraps neon() with slow-query timing proxy
    errors.js         — apiError(), attachRequestId()
    jwt.js            — user-session JWT helpers (separate secret from admin)
    monitor.js        — Sentry envelope wrapper (server-side)
    payout.js         — fee calc + destination validators (BTC/ETH/bank/etc)
    ratelimit.js      — Postgres-backed token bucket
    signals.js        — arb signal pure-functions
    totp.js           — TOTP secret/code/recovery codes (otpauth lib)
    handlers/         — per-module request handlers (admin, payout, bv, …)
  auth/               — login, register, password, me, wallet, google
  data.js             — public data: silver, calendar, blog, tweets
  finance.js          — multi-module router (53 lines — dispatcher only)
  markets.js          — prediction market endpoints
  shop.js             — Stripe + Printful + promo codes + webhook
  wallet.js           — user wallet ledger + withdraw

admin/                — admin panel pages
  _lib/admin.js       — shared client lib: gateAuth, fetch, toast, topbar inject
  *.html              — 11 admin pages (login, index, orders, payouts, blog,
                        promos, admins, webhooks, settings, forgot-password,
                        reset-password)

member/               — logged-in user pages (dashboard, wallet, affiliate, …)
*.html                — public marketing pages
js/                   — client-side scripts for marketing pages
css/                  — stylesheets
data/                 — JSON config (commission rates, BV table, i18n)
scripts/              — CLI: migrate, sitemap, optimize-images, seed
tests/                — Vitest unit tests
```

## Data model (key tables)

| Table | Owner | Notes |
|---|---|---|
| `users` | self-managed | GDPR soft-delete via `deleted_at` + `anonymized` |
| `wallets` | self-managed | `available`, `pending`, `lifetime_earned` |
| `wallet_ledger` | system-written | every credit/debit (dividend, commission, payout) |
| `commissions` | webhook-written | UNIQUE on `(event_id, role, recipient_id)` — idempotent |
| `payouts` | self-submitted, admin-approved | transactional approve/reject |
| `orders` | webhook-written | linked to Stripe session + Printful order |
| `promo_codes` | admin-managed | atomic CAS for redemption count + per-user cap |
| `promo_code_redemptions` | webhook-written | per-redemption row, UNIQUE on `stripe_session_id` |
| `admin_users` | admin-managed | RBAC role + 2FA + bcrypt-hashed recovery codes |
| `admin_audit_log` | append-only (Postgres trigger blocks UPDATE/DELETE) | every state change recorded |
| `admin_password_reset_tokens` | system-written | 60-min single-use |
| `webhook_events` | webhook-written | Stripe event ID claim for idempotency |

Migrations live in `scripts/migrate.mjs`. Every statement is `IF NOT EXISTS` + `ADD COLUMN IF NOT EXISTS` — safe to re-run.

## Auth flows

### User session (`fin_session` cookie)
```
POST /api/auth/register or /api/auth/login
  → bcrypt verify → sign JWT with JWT_SECRET → set HttpOnly cookie
  → cookie carries userId for /api/wallet, /api/auth/me, etc.
```

### Admin session (`fin_admin_session` cookie + `fin_csrf` cookie)
```
POST /api/finance?module=auth_admin&action=login
  body: { email, password, totp_code? | recovery_code? }
  → bcrypt verify → if totp_enabled, verify TOTP or consume recovery code
  → sign JWT with ADMIN_JWT_SECRET (separate from user JWT_SECRET)
  → set TWO cookies:
      fin_admin_session (HttpOnly, Secure, SameSite=Strict, 8h)
      fin_csrf          (Secure, SameSite=Strict, JS-readable, 8h)
  → all subsequent admin POSTs require X-CSRF-Token header
    matching fin_csrf cookie (double-submit pattern)
```

The dual-secret design eliminates privilege escalation: even if `JWT_SECRET` leaks, an attacker can't sign admin tokens because they need `ADMIN_JWT_SECRET` and the `kind: 'admin'` claim.

## RBAC

5 roles, 15 permissions, mapped in `api/_lib/admin-auth.js`:

| Permission | owner | finance | support | content | analyst |
|---|---|---|---|---|---|
| view_dashboard | ✓ | ✓ | ✓ | ✓ | ✓ |
| view_users | ✓ | ✓ | ✓ | – | ✓ |
| view_commissions | ✓ | ✓ | ✓ | – | ✓ |
| view_orders | ✓ | ✓ | ✓ | – | ✓ |
| view_audit_log | ✓ | ✓ | – | – | ✓ |
| view_promos | ✓ | ✓ | ✓ | ✓ | ✓ |
| export_csv | ✓ | ✓ | – | – | ✓ |
| approve_payout | ✓ | ✓ | – | – | – |
| reject_payout | ✓ | ✓ | – | – | – |
| process_refund | ✓ | ✓ | ✓ | – | – |
| dividend_distribute | ✓ | ✓ | – | – | – |
| save_config | ✓ | – | – | – | – |
| manage_blog | ✓ | – | – | ✓ | – |
| manage_promos | ✓ | – | – | ✓ | – |
| manage_admins | ✓ | – | – | – | – |

Handlers gate per-action via `requirePermission(req, res, 'permission_key')`.

## Money flows

### Order → commission → wallet

```
Customer checks out via /api/shop?action=checkout
  → Stripe Checkout session created (server-priced, no tampering)
  → Stripe redirects user; on success Stripe POSTs webhook
/api/shop?action=webhook (verified via stripe-signature header)
  → claim event_id in webhook_events (atomic, ON CONFLICT DO NOTHING)
  → INSERT order row (with promo discount metadata)
  → INSERT promo_code_redemption row + atomic CAS increment counter
  → INSERT commission row(s) (UNIQUE constraint blocks duplicates)
  → UPDATE wallets ON CONFLICT (recipient gets credited)
```

### Payout submit → admin review → paid

```
Member submits via POST /api/finance?module=payout
  → 5/hour rate-limit per user
  → validate destination format per method (TRC20/ERC20/bank)
  → INSERT payouts row, status='pending_review' (or 'paid' if auto-approve)
  → UPDATE wallets.available -= gross_usd

Admin clicks Approve in /admin/payouts.html
  → POST module=admin&action=approve_payout (CSRF + permission gated)
  → sql.transaction([UPDATE status=paid, UPDATE wallets])
  → audit log entry with admin id
```

### Refund

```
Member or admin POSTs /api/shop?action=refund
  → auth: cookie session (member) OR admin with process_refund perm
  → 14-day window check (member-only; admin bypass)
  → 3/hour rate limit (member-only)
  → Stripe refund created (real money moves)
  → UPDATE order status='refunded'
  → audit log entry
```

## Frontend (admin)

Each admin page loads `admin/_lib/admin.js` which:

1. Wraps global `fetch()` to auto-inject `X-CSRF-Token` from cookie + legacy `X-Admin-Key` from localStorage. This lets old code keep using raw `fetch()` while CSRF works.
2. Exposes `AdminClient`:
   - `gateAuth({ require: 'permission' })` — page-level guard, redirects to login on 401, renders 403 on missing permission
   - `fetch()` — same as wrapped global but explicit
   - `toast(msg, isErr)` — ephemeral notification
   - `injectTopbar(currentPage, perms)` — auto-builds nav from `TOPBAR_LINKS` array
   - `captureError(err, ctx)` — hand-thrown Sentry capture
3. Auto-bootstraps the topbar slot on `DOMContentLoaded` if `[data-admin-topbar]` exists.
4. Auto-loads Sentry browser SDK if `<meta name="sentry-dsn">` is present.

## Observability

| Signal | Source | Surface |
|---|---|---|
| Server errors | `api/_lib/monitor.js` (Sentry envelope) | Sentry dashboard |
| Browser errors | `admin/_lib/admin.js` Sentry SDK | Sentry dashboard |
| Slow queries | `api/_lib/db.js` proxy | Vercel logs `[slow-query]` prefix |
| Audit log | `admin_audit_log` table (immutable trigger) | `/admin/index.html` Activity tab |
| Webhook events | `webhook_events` table | `/admin/webhooks.html` |
| Uptime / health | `/api/finance?module=health` | UptimeRobot, custom monitors |
| Daily digest | `/api/finance?module=admin&action=send_digest` | Email to all owners (Vercel Cron 9 UTC) |
| Request IDs | `attachRequestId` middleware | `X-Request-Id` header + audit log |

## Conventions

- Files in `api/_lib/` count as **shared modules**, not Vercel functions. Stay there to avoid the 12-function Hobby cap.
- Every handler returns JSON with shape `{ error, message?, details?, request_id? }` (helper: `apiError()`).
- DB queries that mutate use `sql.transaction([...])` for multi-step changes.
- Admin POSTs require both auth cookie + `X-CSRF-Token` header (auto-handled by `admin/_lib/admin.js`).
- Migrations are append-only; every statement is `IF NOT EXISTS`.
- Audit log writes are fire-and-forget (`.catch(() => {})`) — must never block the main flow.
- Commit messages follow `feat:`, `fix:`, `refactor:`, `security(tier-N):`, `ops(tier-N):`.
