Ctrl K
ComunidadGuías…

ARCHITECTURE.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Rekey — Architecture

A self-hostable auth + billing backend for SaaS / B2C apps. One Rekey
deployment serves many **Tenants** (your customers), each owning many
**Applications** (their products), each owning many **EndUsers** (their
end-users) plus **Plans / Coupons / Licenses / UsageMeters / etc.**

There are two parallel auth pillars:

- **Operators** — humans who run a Tenant via the Rekey panel.
- **End-users** — people who sign into an Application through the SDKs.

This document describes how the system is put together so you can find your
way around the code and extend it.

---

## 1. System diagram

```
                                                        ┌──────────────────────┐
                                                        │  Stripe / PayPal /   │
                                                        │  Razorpay (provider) │
                                                        └─────────▲────────────┘
                                                                  │
                                                  3a. provider-signed webhook
                                                                  │
                                                                  ▼
┌─────────────┐      ┌────────────────────────────────────────────────────────┐
│   Operator  │      │                       Rekey API                       │
│  (tenant    │      │                                                          │
│   admin)    │      │   ┌──────────────────────┐  ┌───────────────────────┐   │
│             │◀────▶│   │  Operator surface    │  │  End-user surface     │   │
│  Panel      │ 1.   │   │  /api/v1/tenant/*    │  │  /api/v1/auth/*       │   │
│  (Next.js)  │      │   │  /api/v1/admin/*     │  │  /api/v1/users/me/*   │   │
└─────────────┘      │   │                      │  │  /api/v1/billing/*    │   │
                     │   │  Auth: TenantUser    │  │  Auth: Application    │   │
                     │   │  JWT (typ=to_access) │  │  API key (rp_live_…)  │   │
                     │   │  with `tid` + `rol`  │  │  + end-user JWT       │   │
                     │   │                      │  │  (typ=eu_access)      │   │
                     │   └─────────┬────────────┘  └─────────┬─────────────┘   │
                     │             └──────────┬───────────────┘                 │
                     │                        │                                 │
                     │             ┌──────────▼───────────┐                     │
                     │             │ Postgres (Prisma)    │                     │
                     │             │ Tenants ─┬─ Apps     │                     │
                     │             │          ├─ EndUsers │                     │
                     │             │          ├─ Plans / Subscriptions / Payments │
                     │             │          ├─ Coupons + CouponRedemptions    │
                     │             │          ├─ Licenses + Activations         │
                     │             │          ├─ UsageMeters + UsageRecords     │
                     │             │          ├─ ApiKeys (hash-only)            │
                     │             │          ├─ RefreshTokens (hash-only)      │
                     │             │          ├─ OAuthIdentities                │
                     │             │          ├─ MfaCredentials (encrypted)     │
                     │             │          ├─ WebAuthnCredential (passkeys)  │
                     │             │          ├─ EmailTemplates                 │
                     │             │          ├─ WebhookEndpoints + Deliveries  │
                     │             │          └─ WebhookEvents (inbound idem.)  │
                     │             └──────────────────────┘                     │
                     │                                                          │
                     │   ┌──────────────────────────────────────────────────┐   │
                     │   │ Outbound side-effects (fire-and-forget)          │   │
                     │   │  emailService.dispatch ──▶ Resend (BYO / pool)   │──▶ 4a. transactional email
                     │   │  webhookService.emit  ──▶ HMAC-signed POST       │──▶ 4b. customer's webhook URL
                     │   └──────────────────────────────────────────────────┘   │
                     └──────────────────────────────────────────────────────────┘
                                                  ▲
                                                  │ 2. SDK call
                                                  │    (Application secret key +
                                                  │     optional X-Rekey-User-Token)
                                                  │
                          ┌───────────────────────┴───────────────────────┐
                  ┌───────▼───────┐                                ┌───────▼───────┐
                  │  Customer's   │  3b. fetch on browser →        │  Customer's   │
                  │   server      │      @rekey.dev/react sends      │   browser     │
                  │  (@rekey.dev/   │      X-Rekey-User-Token      │  (@rekey.dev/   │
                  │   node /      │      direct from JWT cookie    │   react)      │
                  │   nextjs)     │                                │               │
                  └───────────────┘                                └───────────────┘
```

**Numbered flows:**

1. **Operator panel** signs in via `/api/v1/tenant/auth/sign-in`, receives a
   `{tid, rol}` JWT + refresh token as cookies. MFA-enrolled operators pass
   through `/tenant/auth/mfa-verify` before getting tokens.

2. **End-user SDK calls** carry the Application secret key
   (`Authorization: Bearer rp_live_…`) and, for per-user endpoints, an
   end-user JWT (`X-Rekey-User-Token`). The middleware checks that (a) the
   secret key resolves to an Application, (b) the key's `scopes` permit the
   route, and (c) the user JWT's `applicationId` claim matches.

3a. **Inbound billing webhook** from a provider is signature-verified,
   recorded in `WebhookEvent` for idempotency, then dispatched to its handler.
   Subscription/payment state transitions happen here — never in the API
   request path.

3b. **Browser SDK** talks to the customer's own server (with cookies); that
   server forwards to Rekey via `@rekey.dev/node`. The browser never holds the
   secret key.

4a. **Outbound transactional email** via Resend — either a BYO key per
   Application or the Rekey-managed pool. When no transport is configured the
   API returns the raw token to the caller instead (self-host fallback).

4b. **Outbound webhooks** notify the customer's server of user-lifecycle events
   (`user.created`, `mfa.enabled`, `password.changed`, `session.revoked`,
   `email.verified`, …). HMAC-SHA256 signed as
   `X-Rekey-Signature: t=<unix>,v1=<hex>`, with up to 5 retry attempts on
   exponential backoff (30s / 2m / 10m / 1h / 4h).

---

## 2. Auth tiers (trust boundaries)

| Tier | Credential | Issued by | Used by | Scope |
|---|---|---|---|---|
| **Bootstrap admin** | `SUPER_ADMIN_KEY` env | the deployer | `rekey` CLI, first Tenant + Application bootstrap | global; `/api/v1/admin/*` only |
| **Operator session** | `to_access` JWT + 30-day refresh | `/api/v1/tenant/auth/sign-in` | the panel | one Tenant (`tid`) + role (`rol`) |
| **Application secret key** | `rp_live_*` / `rp_test_*` | operator via panel | customer's server (`@rekey.dev/node`) | one Application; `scopes` array gates routes |
| **Application public key** | `rp_pub_<slug>_*` | auto on Application create | customer's browser (`@rekey.dev/react`) | identifier only — never carries privilege |
| **End-user session** | `eu_access` JWT + 30-day refresh | `/api/v1/auth/sign-in` | customer's server forwards to Rekey | one EndUser within one Application |
| **MFA challenge token** | `eu_mfa_challenge` / `to_mfa_challenge` JWT (5 min) | sign-in when MFA enrolled | exchange at `/auth/mfa-verify` | not a session; bound to (user, application, time) |
| **Magic-link token** | 32-byte CSPRNG, SHA-256 in DB (15 min) | `/auth/magic-link/request` | exchange at `/auth/magic-link/verify` | single use; auto-creates user on consume when sign-up enabled |
| **Passkey credential** | WebAuthn credential id + COSE public key (`WebAuthnCredential`) | `/auth/passkey/register/*` | `/auth/passkey/authenticate/*` | Application `rpId` + `rpOrigins`; counter advances per assertion |
| **Impersonation token** | `eu_access` JWT with `imp` claim (5 min, no refresh) | `/tenant/applications/:id/end-users/:euid/impersonate` (OWNER/ADMIN) | end-user routes accept it like a real session | every mint writes an `ImpersonationAudit` row |
| **Password reset / email verify** | 32-byte CSPRNG, SHA-256 in DB | `/auth/forgot-password`, `/auth/send-verification` | single use; reset 1h, verify 24h | (user, application) |
| **Invitation token** | 32-byte CSPRNG, SHA-256 in DB | `/api/v1/tenant/invitations` | accept once | (Tenant, email, role) |
| **Webhook signing secret** | 32-byte hex, one per `WebhookEndpoint` | endpoint creation in panel | inbound HMAC verification by the customer | one endpoint; rotatable |

Every JWT carries a **`typ` claim** (`eu_access`, `eu_mfa_challenge`,
`to_access`, `to_mfa_challenge`) and verifiers refuse the wrong value.
End-user and operator tokens share the same `JWT_SECRET` but cannot
impersonate each other.

---

## 3. The two parallel auth stacks

End-user auth (`modules/auth`) and operator auth (`modules/tenant-auth`)
share primitives (argon2id, JWT, refresh tokens, password reset, MFA) but are
physically duplicated — each authenticates a different DB model
(`EndUser`, Application-scoped, vs `TenantUser`, global-but-multi-Tenant) with
a different JWT claim shape. Where to find each half:

| Concern | End-user | Operator |
|---|---|---|
| Service | `modules/auth/auth.service.ts` | `modules/tenant-auth/tenant-auth.service.ts` |
| Routes | `modules/auth/auth.routes.ts` | `modules/tenant-auth/tenant-auth.routes.ts` |
| Middleware | `middleware/user-session.ts` | `middleware/tenant-session.ts` |
| JWT lib | `lib/jwt.ts` | `lib/tenant-jwt.ts` |
| Refresh token | `lib/refresh-tokens.ts` | `lib/tenant-refresh-tokens.ts` |
| Password reset | `lib/password-reset.ts` | `lib/tenant-password-reset.ts` |
| MFA | `modules/mfa` | `modules/tenant-mfa` |
| Table prefix | `end_users` / `refresh_tokens` / … | `tenant_users` / `tenant_refresh_tokens` / … |

---

## 4. Billing lifecycle

A `Subscription` carries the purchase lifecycle. State transitions happen in
**webhook handlers only** — the API request that initiates checkout creates a
`PENDING` row but does not activate it.

```
   POST /billing/checkout ──▶ ┌─────────┐
                              │ PENDING │
                              └────┬────┘
                checkout.session.completed
                                   ▼
                              ┌─────────┐   invoice.paid
                              │ ACTIVE  │◀────────────┐ (from PAST_DUE)
                              └────┬────┘             │
                  invoice.payment_failed              │
                                   ▼                  │
                              ┌─────────┐             │
                              │ PAST_DUE│─────────────┘
                              └────┬────┘
                customer.subscription.deleted
                                   ▼
                              ┌─────────┐
                              │CANCELED │
                              └─────────┘
```

`EXPIRED` covers Stripe's `incomplete` / `incomplete_expired`. Status mapping
lives in `webhooks/stripe.handler.ts > mapStripeSubStatus`.

- **Coupon redemption** is recorded once at `invoice.paid`, never at checkout
  creation. `CouponRedemption` has a unique index on `(couponId, paymentId)`
  so webhook replays are idempotent.
- **License auto-issue** happens at `checkout.session.completed` for plans of
  `kind = LICENSE`, idempotent on `(applicationId, endUserId, planId)`.
- **Per-Application billing credentials** live in `BillingCredentials`, keyed
  by `(applicationId, provider)`. Each Application can configure any subset of
  `{stripe, paypal, razorpay}`; secrets are AES-256-GCM-encrypted at rest via
  `lib/secrets.ts`.

---

## 5. Outbound side-effects

Every auth flow that mutates user state fires two parallel, fire-and-forget
signals — neither blocks the user-facing response:

1. **Transactional email** via `emailService.dispatch(...)`. Each event has a
   per-Application `EmailTemplate` override or a built-in default. Transport
   resolution: BYO encrypted Resend key per Application → `RESEND_DEFAULT_*`
   env pool → return raw token to caller.
2. **Webhook** via `webhookService.emit(...)`. HMAC-SHA256 signed, up to 5
   attempts on exponential backoff, per-endpoint subscription list (`["*"]`
   for all events).

| Flow | Email event | Webhook event |
|---|---|---|
| Password sign-up | `welcome` | `user.created` |
| OAuth new user | `welcome` | `user.created` |
| Password reset (consume) | `password_changed` | `password.changed` |
| Authenticated change-password | `password_changed` | `password.changed` |
| MFA confirm | `mfa_enabled` | `mfa.enabled` |
| MFA disable | — | `mfa.disabled` |
| Email verify (consume) | — | `email.verified` |
| Forgot-password issue | `password_reset` | — |
| Send verification | `email_verification` | — |
| Workspace invitation | `workspace_invitation` | — |
| `DELETE /sessions/:id` | — | `session.revoked` |

---

## 6. Security model

The mechanics a contributor must preserve when touching auth or billing:

- **`typ` JWT claim is load-bearing.** End-user and operator JWTs share
  `JWT_SECRET`; the `typ` claim is what stops one from being accepted as the
  other. Always set and verify it.
- **Hash-only credentials at rest.** API keys, refresh tokens, password-reset
  / email-verification / invitation tokens, license keys and MFA backup codes
  are stored as SHA-256 hashes. Raw values are shown once at issue and are not
  queryable.
- **Encrypted secrets at rest.** Anything whose cleartext is needed later
  (provider API keys, OAuth client secrets, Resend keys, MFA TOTP secrets) is
  AES-256-GCM-encrypted via `lib/secrets.ts`. A `v1.…` prefix allows algorithm
  rotation.
- **Constant-time credential compare.** `timingSafeEqual` in `lib/keys.ts` and
  `middleware/admin-auth.ts`; SHA-256 lookups on indexed columns are
  constant-time at the DB layer.
- **Cross-tenant guard at every authenticated entry point.** The end-user JWT
  carries `applicationId`, the operator JWT carries `tid`; the resolving
  middleware refuses mismatched claims.
- **API key scope hierarchy** via `requireScope(scope)`: `*` ⊃ everything,
  `auth:write` ⊃ `auth:read`, `billing:write` ⊃ `billing:read`. Leaf scopes
  (e.g. `webhooks:read`) must be granted explicitly.
- **State transitions only from provider events.** The `PENDING` row is
  bookkeeping; only `checkout.session.completed` / `invoice.paid` move it to
  `ACTIVE`.
- **Idempotency via DB unique constraints**, not application-layer caches:
  `WebhookEvent.(provider, providerEventId)` (inbound), `Payment.
  providerPaymentId`, `CouponRedemption.(couponId, paymentId)`,
  `LicenseActivation.(licenseId, machineFingerprint)`.
- **Outbound webhook SSRF guard.** `isWebhookUrlSafe` rejects
  loopback/private/link-local/CGNAT IPs and non-HTTP schemes at endpoint
  creation. Opt out for intra-VPC self-hosting with
  `WEBHOOK_ALLOW_PRIVATE_TARGETS=true`.
- **OAuth auto-link requires `emailVerified: true`** from the provider;
  unverified emails are refused (`OAUTH_EMAIL_NOT_VERIFIED`).
- **Per-route security posture:**

| Surface | Auth | Cross-tenant guard | Rate limit | CORS |
|---|---|---|---|---|
| `/api/v1/auth/*` | Application secret key (+ `eu_access` JWT when authenticated) | `applicationId` claim(s) must match | 100/min/IP | strict allowlist |
| `/api/v1/users/me/*` | Secret key + `eu_access` JWT | same | global | strict allowlist |
| `/api/v1/billing/*` | Secret key + (optional) `eu_access` JWT | same | global | strict allowlist |
| `/api/v1/billing/webhook/*` | Provider HMAC signature | `metadata.applicationId` on event | global | n/a (server-to-server) |
| `/api/v1/tenant/*` | `to_access` JWT | `tid` claim + membership re-check from DB | global | strict allowlist |
| `/api/v1/admin/*` | `SUPER_ADMIN_KEY` constant-time compare | n/a (bootstrap only) | global | strict allowlist |

---

## 7. Repository layout

```
apps/
  api/                       Fastify backend (the core)
    src/
      app.ts                 — Fastify build, plugin registration, CORS, raw-body
      config/env.ts          — env schema (single source of truth for config)
      lib/                   — primitives (no business logic)
        jwt.ts / tenant-jwt.ts            — typ-discriminated JWTs
        refresh-tokens.ts / tenant-refresh-tokens.ts
        password-reset.ts / tenant-password-reset.ts
        email-verification.ts
        passwords.ts         — argon2id hash + verify
        keys.ts              — API key generation + SHA-256
        license-keys.ts
        mfa.ts               — TOTP + backup codes
        webauthn.ts / tenant-webauthn.ts  — SimpleWebAuthn wrappers
        secrets.ts           — AES-256-GCM encrypt/decrypt
        email-transport.ts   — Resend BYO + default pool
        webhook-signing.ts   — HMAC sign / verify + SSRF URL safety
        prisma.ts            — Prisma client singleton
        error.ts             — RekeyError + Fastify error handler
      middleware/
        api-key-auth.ts      — Application secret key + requireScope
        user-session.ts      — eu_access JWT verification + applicationId guard
        tenant-session.ts    — to_access JWT verification + workspace re-check
        admin-auth.ts        — SUPER_ADMIN_KEY constant-time compare
      modules/
        auth/                — end-user auth (sign-up / in / refresh / reset / verify)
        tenant-auth/         — operator auth (parallel)
        oauth/               — OAuth providers + callback + link/unlink
          providers/         — google, github, microsoft, discord, gitlab, slack, oidc
        mfa/ tenant-mfa/     — TOTP enroll / confirm / challenge / disable
        passkeys/ tenant-passkeys/ — WebAuthn register / authenticate
        api-keys/            — API key CRUD + scopes
        end-user-roles/      — per-Application RBAC catalog
        organizations/       — end-user organizations + memberships + invitations
        tenants/ applications/ — bootstrap admin CRUD
        tenant-applications/ — tenant-scoped Application + plan + coupon + api-key CRUD
        tenant-workspaces/   — workspace members + invitations
        billing/             — plans + checkout + provider selection
          providers/         — stripe, paypal, razorpay
          webhooks/          — inbound provider webhook handlers
        plans/ coupons/ licenses/ usage/  — billing-tier surfaces
        email/               — template registry, render, transport, routes
        webhooks/            — outbound webhook endpoint CRUD + delivery + retry
      routes/                — top-level glue (health, me, users-me)
    test/                    — vitest suites
  panel/                     — Next.js operator panel (App Router, server actions, Tailwind)
  portal/                    — hosted multi-app customer billing portal
packages/
  shared-types/              — Zod schemas + types shared between API and SDKs
  sdk-node/                  — server SDK (Application secret key)
  sdk-react/                 — browser SDK (public key)
  sdk-nextjs/                — Next.js server-action + middleware helpers
  cli/                       — rekey CLI
  mcp/                       — Model Context Protocol surface
prisma/
  schema.prisma              — single Prisma schema for the whole monorepo
  migrations/                — SQL migrations
```

---

## 8. Configuration (env)

Required to boot: `DATABASE_URL`, `JWT_SECRET`, `SUPER_ADMIN_KEY`, and a
reachable Redis. Everything else is optional or has a default.

| Variable | Purpose | Required? |
|---|---|---|
| `DATABASE_URL` | Postgres connection | **required** |
| `JWT_SECRET` | signs end-user + operator + MFA-challenge JWTs (≥32 chars) | **required** |
| `SUPER_ADMIN_KEY` | bootstrap admin path (≥32 chars) | **required** |
| `REDIS_URL` | outbound-webhook delivery queue + rate-limit/lockout state | **required infra** — the API refuses to boot if Redis is unreachable; the URL itself defaults to `localhost` |
| `ENCRYPTION_KEY` | AES-256-GCM for secrets at rest (64 hex chars) | **required** outside the test suite (boot fails without it) |
| `CORS_ALLOWED_ORIGINS` | allowlist for browser callers | schema-optional, so the API boots without it — but an empty value in production denies **every** cross-origin caller (dev permits localhost) |
| `RESEND_DEFAULT_API_KEY` + `RESEND_DEFAULT_FROM` | shared Resend pool; without it the API returns raw tokens to the caller | optional |
| `WEBHOOK_ALLOW_PRIVATE_TARGETS` | opt out of the outbound-webhook SSRF guard for intra-VPC URLs | optional (self-host) |
| `OPERATOR_SIGNUP_MODE` | `open` \| `invite` \| `closed` — who may create a new operator account | defaults `open`; the API logs a `[SECURITY]` warning while `open` + `NODE_ENV=production` |
| `OPERATOR_MCP_DYNAMIC_REGISTRATION` | `open` \| `disabled` — RFC 7591 registration on the operator MCP authorization server | defaults `open`; close it once your MCP clients hold a `client_id` |
| `TRUSTED_PROXIES` | hop count or IP/CIDR allowlist deciding whether `X-Forwarded-For` is believed | required behind a reverse proxy — `request.ip`, rate-limit buckets and IP allowlists key off it |
| `REKEY_COOKIE_SECURE` | forces the `Secure` cookie attribute on/off in the **web apps** (panel/portal + `@rekey.dev/nextjs`); the API sets no cookies | optional — `Secure` is otherwise decided per request from `X-Forwarded-Proto`, falling back to the host |

See [.env.example](.env.example) for the complete, commented list.

See [DEPLOY.md](DEPLOY.md) for a full self-host runbook and [docs/](docs/) for
per-feature guides (auth, billing, API keys, MCP, portal, …).