Architecture

May 20, 2026 · View on GitHub

This is the load-bearing reference for how the server is put together — the modules, the layered defenses, the design choices we are not going to revisit casually. If you only have ten minutes, read this once and then keep it open while you work.

For how to write code that fits this architecture, see code-guidelines.md. For how to contribute changes, see ../CONTRIBUTING.md.

What this server is

A template-shaped NestJS server: many projects share the same src/core/, each project adds its own resources in src/modules/. The template itself is not a deployable application — consumers fork or sync, then deploy. See customization-guide.md for the consumer perspective.

Tech stack

LayerChoiceWhy
RuntimeBun 1.x (Node 22 fallback)TypeScript-first, fast cold start, native test runner
FrameworkNestJS 11DI, decorators, modular, runs on Bun
LanguageTypeScript 5.9+ strictNo implicit any, no @ts-ignore
ORMPrisma 7 (driver-adapter mode)Typed, migrations, Postgres-first
DBPostgres 18RLS, JSONB, FTS, LISTEN/NOTIFY, pg_uuidv7
AuthBetter-Auth 1.6Email/PW, OAuth, 2FA, Passkey, sessions, JWT — one stack
AuthZCASL 6 + DB-persisted rulesIndustry standard; we own the persistence, not the engine
ValidationZod 4Single SoT for DTOs and OpenAPI schemas
APIREST + OpenAPI 3.1 + Scalar UINo GraphQL by design
StorageS3 / Local / PostgresThree adapters, one interface
EmailNodemailer + BrevoSMTP for dev, Brevo for prod
WebhooksBullMQ + HMAC-SHA256Standard-Webhooks spec, see webhook-spec.md
SearchPostgres FTS (tsvector + GIN)No external infra
RealtimePostgres LISTEN/NOTIFY + Socket.IO + Redis adapterSocket.IO cross-pod fanout via Redis when REDIS_URL set
Mobile syncPowerSync + SQLite clientOffline-first
EncryptionAES-256-GCM via @47ng/cloakField-level, key-versioned
GeoPostGIS + Mapbox/Nominatim/Google adapterStandard Postgres-Geo, GeoJSON I/O
JobsBullMQ + RedisRepeatable crons + ad-hoc queue; in-memory fallback when REDIS_URL unset
Rate limit@nestjs/throttler + Postgres storeMulti-instance
ObservabilityOpenTelemetry (OTLP) + PinoTraces + metrics + correlated logs
ErrorsRFC 7807 Problem Detailsapplication/problem+json
IDsUUID v7Time-sorted, B-tree friendly
TestsVitest 4 (Bun test for perf only)Bigger plugin ecosystem
Lint/formatoxlint + oxfmtRust-based, fast
API styleRESTNo GraphQL, no subscriptions outside Socket.IO
Dev-Portal SPAReact 19 + react-router-dom 7Single SPA, every dev/admin/errors/openapi page
Dev-Portal UIshadcn/ui (Radix) + Tailwind CSS 4 + lucide-react + sonnerVendored primitives, CSS-first @theme, tree-shaken icons, toasts

Out of scope (do not add)

RemovedReason
GraphQL / ApolloREST + OpenAPI is sufficient and halves complexity
Legacy CoreAuthServiceBetter-Auth covers everything
Vendor-ModeWorkaround for code comprehension; greenfield doesn't need it
MailjetBrevo covers all use cases
Mongoose / MongoDB / GridFSPrisma + Postgres + S3 storage
@UnifiedField decoratorPrisma + Zod are the SoT
Self-built @Restricted/@RolesReplaced by DB-configurable CASL permissions
process()-style raw pipelinesReplaced by clear service vs. repository split

Repository layout

src/
├── main.ts
├── app.module.ts
├── core/                            ← Template-owned. Synced via bun run sync:from-template.
│   ├── auth/                        ← Better-Auth integration
│   ├── permissions/                 ← CASL engine + DB persistence
│   ├── output-pipeline/             ← 4-stage interceptor (translate → CASL → filter → secrets)
│   ├── multi-tenancy/               ← Tenant resolution + Postgres RLS
│   ├── files/  storage/             ← Directus-style files, pluggable storage adapters
│   ├── email/  webhooks/            ← Outbound channels
│   ├── search/  realtime/           ← FTS, Socket.IO + LISTEN/NOTIFY
│   ├── encryption/  geo/  mcp/      ← PII encryption, PostGIS, Model-Context-Protocol
│   ├── jobs/  outbox/               ← BullMQ queue + outbox pattern for reliable events
│   ├── errors/  audit/              ← RFC 7807, audit log
│   ├── request-context/             ← AsyncLocalStorage per request
│   ├── observability/               ← OpenTelemetry setup
│   ├── dx/  dev/                    ← Scalar, NestJS DevTools, dev hub
│   ├── openapi/                     ← Zod → OpenAPI bridge (decorators + named-schema registry)
│   ├── features/                    ← FeaturesSchema (zod) — single source of truth for toggles
│   └── http/  validation/  …
├── modules/                         ← Project-owned. Add your domain code here.
└── shared/                          ← Cross-tier types (channels, events, SDK seeds).
prisma/
├── schema.prisma                    ← Core schema, always present
└── features/                        ← Feature-gated schemas, concatenated by bun run prepare:schema

For the core/modules/ boundary, see customization-guide.md. For what counts as public surface, see api-stability-promise.md.

Permission model

The system has three layers of authorization, applied in order on every read and every write — defense in depth:

  1. Application layer (CASL)can(action, subject, conditions) resolved per request from DB-persisted rules. Field-level and item-level. This is the layer that decides whether a handler runs and which fields it may write.
  2. Repository layer (Prisma WHERE)accessibleBy(ability, 'read') produces a Prisma filter that is AND-merged into every read query. This is the layer that ensures a findMany never returns rows the user can't see — even if the handler forgets to filter.
  3. Database layer (Postgres RLS) — tenant-isolation enforced by the database itself. This is the last-resort backstop: even a SQL injection that bypasses the ORM hits an RLS policy.

CASL is the engine, we own the persistence. The schema:

Role  ──→  RolePolicy  ──→  Policy  ──→  Permission(resource, action, itemFilter, fields, validation, presets)
  • itemFilter is a JSON filter expression (Directus DSL — _eq, _in, _and, _or, etc.) with variable markers ($CURRENT_USER, $CURRENT_TENANT, $NOW) resolved at request time.
  • fields is a string-array allowlist. fields = [] means "no field-level restriction" — see OPEN_QUESTIONS.md for the rationale (CASL cannot represent "deny every field" in a single rule; the deny case is expressed by simply not granting the action).
  • presets are default values applied on CREATE.
  • validation is a JSON schema applied on CREATE/UPDATE.

Administrator and Public are system roles — Administrator bypasses every check; Public applies to unauthenticated requests.

Storage adapter & default rulesPrismaPermissionStorage (src/core/permissions/prisma-permission-storage.ts) is the default backing store. On every findRulesForUser(userId, tenantId) it joins the Role → RolePolicy → Policy → Permission graph for the caller's membership AND appends a synthesized "Member" ruleset (buildMemberRoleRules()) for users with an ACTIVE TenantMember row in the requested tenant. The synthesized rules grant manage on every project-facing resource subject scoped to $CURRENT_TENANT — without them a fresh sign-up would 403 on every @Can()-gated route because the explicit Permission table is empty until an admin writes to it. The synthesis is opt-out (new PrismaPermissionStorage(prisma, { synthesizeMemberRules: false })) for projects that ship their own seeded Member role.

Lifecycle — the active Ability is attached to req.ability by AbilityMiddleware (NOT an interceptor), so it is available to every guard. NestJS' request lifecycle is middleware → guards → interceptors → handler; attaching the ability in an interceptor would make CanGuard always see undefined and 403 every authenticated request. TestAbilityMiddleware runs first across the chain and lets NODE_ENV=test specs pre-seed the ability via the X-Test-Ability header.

Route-gating policy + CI gate (Issue #47) — every controller route MUST carry exactly one of:

  1. @Can(action, subject) — gated by the CASL ability layer above.
  2. @Public("<reason>") — explicit consent that the route is anonymous-by-design (with the rationale at the decoration site).
  3. A path on the runtime allowlist (PUBLIC_PREFIXES / PUBLIC_EXACT in src/core/auth/jwt-middleware.ts, EXEMPT_* in src/core/multi-tenancy/tenant-guard.ts).

The build-time CI gate (tests/stories/route-gating-audit.story.test.tssrc/core/permissions/route-audit-planner.ts) walks every *.controller.ts and *.module.ts under src/, parses each HTTP- method decorator + the surrounding @Can / @Public decorators, and fails the suite if a route slips through with neither marker. The current snapshot lives in docs/security/route-audit-2026-05-02.md.

Output pipeline

CASL handles read visibility (item filter) and static field allowlists. For instance-dependent filtering (masking, cross-lookups, computed visibility) we run a four-stage pipeline as a global interceptor:

Service returns Plain Object(s) from Prisma
  ↓ Stage 1   i18n translate (Accept-Language → _translations)
  ↓ Stage 2   CASL field allowlist (permittedFieldsOf → strip)
  ↓ Stage 3   Filter-Service (per-resource @FilterFor, async, DI-aware)
  ↓ Stage 4   Secret safety net (DEFAULT_SECRET_FIELDS + *Hash/*Token/*Secret regex)
HTTP response

Order matters: translate before allowlist (otherwise _translations gets stripped); allowlist before filter-service (filters see only permitted fields); secret safety net last (independent of everything else).

Filter services live in src/core/output-pipeline/ and src/modules/<resource>/<resource>.filter.service.ts. They register themselves via @FilterFor('Subject') and implement applyInstance(item, ctx) — return null to drop the item entirely, return the (possibly modified) item to keep it.

The secret safety net is non-negotiable. Even if a permission mistakenly grants a secret field, even if a filter forgets to strip it, the safety net removes it. New secret-shaped fields (*Hash, *Token, *Secret) are picked up automatically.

Zod → OpenAPI bridge

The slim-module reference (src/modules/example/) and any new domain module use Zod schemas as the single source of truth for runtime validation, TypeScript types, and OpenAPI schemas. The bridge that closes the OpenAPI loop lives in src/core/openapi/:

FileRole
zod-to-openapi.tsPure planner. Wraps z.toJSONSchema(schema, { target: 'openapi-3.0' }) so the result is OpenAPI-3.0-compatible (no $schema keyword, no draft-2020 type-arrays). Also owns the named-schema registry: registerZodSchema('Foo', schema) makes the schema available as components.schemas.Foo in the document.
zod-api-decorators.ts@ApiZodBody, @ApiZodResponse, @ApiZodOkResponse, @ApiZodCreatedResponse, @ApiZodNoContentResponse, @ApiZodQuery, @ApiZodParam — thin wrappers over @nestjs/swagger's Api* decorators that take Zod schemas instead of class types. The schemas land in the @nestjs/swagger metadata pipeline, so SwaggerModule.createDocument(...) picks them up like any other annotation.
zod-openapi-bridge.tsBoot-time runner. After createDocument(...) runs, applyZodSchemaRegistry(doc) splices the named-schema registry and the RFC 7807 problem-details components into components.schemas / components.responses. Existing entries are preserved, never overwritten.

Why this matters: without the bridge, a Zod-validated route reaches the OpenAPI document with no body / response schema, so the kubb-generated SDK types every endpoint as body?: never / 200: unknown. The frontend's Backend Types: Generated only rule depends on the bridge being wired.

The slim-module reference and the user-profile module are the two canonical examples — copy their decorator pattern when you scaffold a new module.

/api/openapi.json is the canonical OpenAPI doc URL. /api-docs-json is mounted as a deprecated alias for legacy nuxt-base-starter installations whose openapi-ts.config.ts fallback still hardcodes the path used by older nest-server-starter releases. The alias returns the same document plus Deprecation (RFC 8594) and Link: rel="successor-version" (RFC 8288) headers pointing clients at the canonical URL. It will be removed once the upstream fix (lenneTech/nuxt-base-starter#13) has propagated to all consumer workspaces.

Multi-tenancy (single feature: multiTenancy)

Tenants are Better-Auth Organizations (organization / member / invitation tables). One feature flag (FEATURE_MULTI_TENANCY_ENABLED) turns on the full stack: BA org plugin, session activeOrganizationId, session activeOrganizationId, and Postgres RLS.

How tenant scope is resolved

SurfaceMechanism
All gated routes (/api/*, /admin/*, /hub/*)POST /api/auth/organization/set-activesession.activeOrganizationId. Stray x-tenant-id headers are ignored.
Hub HTML (no session org yet)resolveHubOperatorTenantId may pick a default membership for shell render only.
BootstrapGET /api/me/tenants, POST /api/tenants — exempt from tenant scope (no active org yet).

Policy: src/core/multi-tenancy/tenant-resolution-policy.ts. Resolver: resolveRequestTenantId(req, prisma, { path }).

Two-layer data isolation

  • App layerTenantInterceptor + CASL $CURRENT_TENANT from the resolved org id (AsyncLocalStorage).
  • DB layer — Postgres RLS via SET LOCAL app.tenant_id in PrismaService.runWithRlsTenant().

If app code forgets to scope a query, RLS denies the rows. If RLS is misconfigured, CASL still denies the rows. Both layers must fail for a tenant leak to occur.

Tenant self-service surface

RoutePurposeAuthTenant scope
GET /api/me/tenantsList memberships for the callerrequiredexempt
POST /api/tenantsCreate org + owner membershiprequiredexempt
/api/* (domain)CRUDrequiredsession active org
/admin/*, /hub/*Operator toolsrequiredheader or session

See src/core/multi-tenancy/tenant-self-service.module.ts and tenant-guard.ts exempt lists.

Cross-cutting subsystems

These live in src/core/ and are activated via features.ts:

SubsystemPathPurpose
Webhookssrc/core/webhooks/Outbound HMAC-signed events; see webhook-spec.md
Realtimesrc/core/realtime/LISTEN/NOTIFY → Socket.IO, permission-aware rooms, dev-only inspector state with PII-masked event ringbuffer
MCPsrc/core/mcp/Model Context Protocol server, OAuth 2.1 (PKCE)
Outboxsrc/core/outbox/Reliable event publishing (DB-write + dispatch in one tx)
Auditsrc/core/audit/Append-only audit log, write-only by app, read-only via admin. The Prisma audit + audit-stamp extensions (src/core/repository/prisma-extensions.ts) auto-emit (action, target, diff.before/after, metadata) rows on every CUD against opted-in models (Tenant, TenantMember, Role, RoleAssignment, Policy, Permission, ApiKey); impersonation lifecycle events surface as INVOKE audit rows via DefaultImpersonationAuditSink
Jobssrc/core/jobs/BullMQ job queue + in-memory fallback
Authsrc/core/auth/Better-Auth core with 9 plugins (jwt/twoFactor/passkey/admin/organization/magicLink/oneTap/openAPI/apiKeys); 24h VerificationCleanupCron prunes stale verifications rows older than 7 days (Better-Auth doesn't auto-prune)
Idempotencysrc/core/idempotency/Stripe-style Idempotency-Key header; Postgres-backed (idempotency_records) with a 24h cleanup cron pruning expired rows
Concurrencysrc/core/concurrency/ETag / If-Match optimistic-lock
Encryptionsrc/core/encryption/AES-256-GCM field-level encryption via MultiKekFieldEncryption Prisma extension. Per-write fresh IV; primary KEK from FIELD_ENCRYPTION_KEK, comma-separated FIELD_ENCRYPTION_LEGACY_KEKS activates the read-side fallback so KEK rotation succeeds without a re-encryption pass
Filessrc/core/files/Pluggable storage adapters (local / S3 / Postgres / RustFS), TUS resumable uploads, IPX image transforms with a Postgres-backed variant-cache index (asset_variant_index) + 24h cleanup cron pruning rows older than 90 days
Geosrc/core/geo/PostGIS + geocoding adapters with a 24h cache-cleanup cron

All are opt-in via features.ts — disabled features have zero footprint (no module load, no migration, no env-var requirement).

Email subsystem

src/core/email/ ships three drivers behind the EmailDriver interface:

DriverPathUsed when
SmtpEmailDriversrc/core/email/drivers/smtp.driver.tsfeatures.email.provider === "smtp" and SMTP_HOST is set. Wraps Nodemailer with a connection pool + 10 s timeouts.
BrevoEmailDriversrc/core/email/drivers/brevo.driver.tsfeatures.email.provider === "brevo" and BREVO_API_KEY is set. Pure-fetch HTTP client against https://api.brevo.com/v3/smtp/email. Also exposes listTemplates() / getTemplate() for the Issue #9 read-only Hub tab.
LogOnlyEmailDriversrc/core/email/email.module.tsfeatures.email.enabled === false or no relay configured at all (offline dev). Mails go to Pino log lines instead of out the wire.

The driver-selection planner selectEmailDriver() picks primary + optional transactional from features + env. With provider="smtp" and BREVO_API_KEY set, Brevo is wired only as the transactional driver — EmailService.sendTemplate({ brevoTemplateId }) then reaches Brevo while plain EmailService.send(...) keeps using SMTP.

Local-dev loop

docker compose up -d mailpit starts a Mailpit container on localhost:1025 (SMTP) and localhost:8025 (web inbox). The default .env.example already points SMTP_HOST/PORT at it, so the very first EmailService.send(...) lands visibly in http://localhost:8025 without any extra configuration.

Two compatibility flags matter:

  • SmtpEmailDriver sets allowInternalNetworkInterfaces: true because Nodemailer ≥ 7 blocks loopback / private addresses by default (SSRF guard).
  • The Mailpit container is started with --smtp-disable-rdns because Mailpit's reverse-DNS lookup of the connecting client IP blocks the greeting for ~5 s on Docker bridge networks (no PTR records).

Brevo setup

  1. Create an API key at https://app.brevo.com/settings/keys/api.
  2. Set BREVO_API_KEY=xkeysib-... in .env.
  3. Either flip FEATURE_EMAIL_PROVIDER=brevo to make Brevo the primary, or keep provider=smtp and use Brevo only for templates via sendTemplate({ brevoTemplateId }).
  4. Templates created in the Brevo UI become callable by ID; the read-only Hub tab (Issue #9) lists them via BrevoEmailDriver.listTemplates().

Outbox-style retry / DLQ / bounce handling is a separate slice (Issue #11) — the drivers themselves return success/failure for a single attempt and let the outbox decide what to do next.

Hub (Dev-Portal Frontend)

Every developer-facing HTML surface — /, /hub/*, /admin/*, /errors, /openapi — is served by a single React 19 single-page app. The legacy server-rendered *-ui.ts renderers were deleted; the SPA is the canonical UI for every developer route. /hub/* and /admin/* are developer-only (every route 404s outside NODE_ENV=development); /errors and /openapi stay reachable in any environment because frontends + SDK generators read them.

AspectPathPurpose
Shell renderer (planner)src/core/dx/dev-portal-shell.tsPure function: title + script URL + token CSS URL → static HTML5 skeleton with <div id="root">
SPA source treesrc/core/dx/clients/Browser-only: main.tsx (entry), App.tsx (router), layout/, pages/, components/, lib/, styles/
Layout shellsrc/core/dx/clients/layout/AdminShell.tsx + nav.ts + icons.tsxSidebar + header + SVG icons + active-state highlight
Pagessrc/core/dx/clients/pages/One component per route: HubLoginPage (/, Better-Auth email/password), HubLandingPage (/hub), FeaturesPage, … — each lazy-loaded via React.lazy
Hub portal authsrc/core/hub/hub-portal-paths.ts, hub-portal.middleware.ts, hub-portal-access.ts, bootstrap.ts GET /Better-Auth session required for /hub/* and /admin/* (except /hub/static/*); read Hub CASL subject; login at / via HubLoginPage
UI primitivessrc/core/dx/clients/components/ui/shadcn/ui components vendored under this tree (badge, button, card, checkbox, dialog, dropdown-menu, input, label, progress, radio-group, select, separator, sheet, sonner, switch, table, tabs, textarea, tooltip), built on Radix UI. To add a primitive: copy the canonical source from https://ui.shadcn.com/docs/components, retarget imports to ../../lib/utils.js, append the .js suffix to every relative import.
Custom componentssrc/core/dx/clients/components/JsonViewer (reused by /errors, /openapi, /hub/postgrest-parse), PageState (Loading / Error / Empty / StatTile helpers), Sparkline (Webhook-Inspector trends)
Iconssrc/core/dx/clients/layout/icons.tsxSidebar + page icons via lucide-react — single import, tree-shaken to ~3 KB gzipped, consistent stroke-width 1.75
ToastssonnerNotification primitive mounted in main.tsx + the <Toaster> component, used for save / delete confirmations across /hub/* and /admin/*
Styling stacksrc/core/dx/clients/styles/globals.cssTailwind CSS 4 with the CSS-first @theme config — @import "tailwindcss" + @theme inline { --color-background: var(--bg); … }. Built via bun-plugin-tailwind, hot-reloaded by the dev-portal watcher.
Design tokenssrc/core/dx/clients/styles/tokens.css:root custom properties (electric-lime accent, near-black surfaces) — declared once, overridden at runtime by brand.json (Issue #5), aliased into Tailwind utilities through the @theme bridge above
Build scriptscripts/build-dev-portal.tsBun.build({ target: "browser", splitting: true, minify: true })dist/dev-portal/
/api/hub/* JSON sidecarshub.controller.tsdashboard.json, feature-catalog.json, coverage.json, tests.json, diagnostics.json, logs.json, traces.json, queries.json, routes.json, erd.json, email-preview.json, email-builder/templates.json (with overridesCore/overrideExists flags), email-builder/blocks.json, email-builder/templates/:name/composition.json (Issue #49 — decompose .tsx source back to JSON composition), migrations.json
/api/hub/email-builder/* mutating endpointshub.controller.ts + src/core/email/email-builder.tspreview.json (POST — render draft), save (POST — codegen .tsx to src/modules/email/templates/), templates/:name/override (DELETE — Issue #49 reset-to-default); defense-in-depth path validation, 404 outside development
/api/hub/migrations/* mutating endpointshub.controller.ts + migrations/migrations.service.tsdeploy, apply-one, dry-run, retry, create, apply-draft, draft/:name (DELETE) — Postgres advisory-lock-gated, 404 outside development
/api/admin/* JSON sidecarsadmin-spa.controller.tspermissions/test.json, webhooks.json, realtime.json, realtime/channels.json, audit.json, search.json
/api/admin/* POST actionsadmin-spa.controller.tsrealtime/sockets/:id/disconnect, realtime/sockets/:id/send, realtime/events/replay — all dev-only, all 404 in production
Static asset endpointGET /hub/static/:filename404 outside development; allow-list filename, MIME-detect, stream from dist/dev-portal/
Catch-allGET /*splatReturns the SPA shell so client-side routes work without a server change
Server tsconfigtsconfig.json (excludes src/core/dx/clients/**)Server build never sees browser code
Client tsconfigtsconfig.client.jsonjsx: "react-jsx", lib: ["ES2022","DOM","DOM.Iterable"], types: []

Build & dev-loop

  • bun run build:dev-portal produces dist/dev-portal/main.js (+ code-split chunks + main.css + tokens.css). The Tailwind oxide compiler runs through bun-plugin-tailwind so the CSS bundle only contains classes the SPA actually references — Tailwind purge keeps main.css lean while shadcn primitives stay first-class. Bundle budget: ≤ 1.2 MB total (all chunks) for the Base-SPA (no Monaco, no TipTap).
  • bun run dev runs an awaited initial portal build before the API child spawns, then starts bun run build:dev-portal --watch for incremental rebuilds (~80 ms warm). This eliminates the startup race where a request to /hub/static/main.js could hit a missing bundle.
  • bun run setup writes .env, optionally bootstraps Postgres/Redis + schema/migrations/seed (--skip-bootstrap to opt out), and builds the SPA once so /hub/static/main.js exists before the first dev start.

Coverage

src/core/dx/clients/** is excluded from the ≥ 80 % core lines coverage threshold (see vitest.config.ts). UI glue is exercised manually in development and by future Chrome-DevTools-MCP smoke tests; the shell renderer keeps a story test (tests/stories/dev-portal-shell.story.test.ts) because it crosses the trust boundary (server → browser).

Conventions

  • Native HTML inputs in net-new pages are forbidden. Every interactive primitive on a brand-new page comes from components/ui/ (Button, Input, Select, Switch, Checkbox, Tabs, Dialog, Sheet, DropdownMenu, …) — the shadcn/ui primitives layered on Radix. The page body is composed with Tailwind utility classes (bg-surface-2, text-fg-muted, border-line, text-accent, bg-ok/15 text-ok, …) that resolve through the @theme bridge in styles/globals.css to the brand-aware tokens in styles/tokens.css. The full inventory of available primitives + variants lives in shadcn/ui primitives under components/ui/ (no separate showcase route).
  • No process.env.* / Node imports. This tree is browser-only; tsconfig.client.json excludes Node types so this fails at compile time.
  • No server-rendered HTML left. Every Controller returning HTML returns the Hub SPA shell; React + react-router decide what to render based on the URL.

The detailed walkthrough (how to add a page, how to add a primitive, which Tailwind utilities map to which token, build pipeline) lives in src/core/dx/clients/CLAUDE.md. The skill for adding new pages is .claude/skills/extending-hub.md.

Security mechanisms (overview)

LayerMechanism
NetworkTLS via reverse proxy, HSTS
BootENV validation (Zod), assertCookiesProductionSafe(), fail-fast
CORSAuto-derived from BASE_URL/APP_URL; opt-in allowedOrigins[]
CookieshttpOnly, Secure, SameSite=Lax (default), signed
AuthBetter-Auth (sessions + JWT), 2FA, Passkey, rate-limit, brute-force lockout
API keysargon2id hash, scopes, expiry, revocation
AuthZCASL + DB rules, field-level + item-level, presets, validation
Output4-stage pipeline, secret safety net
Field encryptionAES-256-GCM, key versioning, optional blind-index
WebhooksHMAC-SHA256, replay protection, auto-disable
RealtimePermission-aware rooms, auth handshake
Mobile syncSync rules ⊆ READ permissions, JWT audience validation
Tenant isolationApp layer + RLS
InputZod pipe, mime-magic-byte for files
DBRLS, FK with ON DELETE, soft-delete
FilesMime allowlist, magic-byte, path-traversal guards, signed URLs
LoggingPino + OTel, W3C trace context, no PII in logs
Rate limit@nestjs/throttler + Postgres store, multi-window
HeadersHelmet (HSTS, X-Content-Type-Options, X-Frame-Options, CSP)
IdempotencyIdempotency-Key header, 24h cache
ConcurrencyETag / If-Match
ErrorsRFC 7807 (application/problem+json)
SecretsNever in code; Better-Auth secret 32+ chars; rotation supported
Dependenciesbun audit gate in CI, Renovate

Initial data model

Auth        User · Account · Session · VerificationToken · TwoFactor · Passkey · Jwks · ApiKey
Tenancy     Tenant · TenantMember
Permission  Role · Policy · RolePolicy · Permission
Files       FileFolder · File · FileBlob · AssetPreset · AssetVariantIndex
Webhooks    WebhookEndpoint · WebhookDelivery
Realtime    RealtimeSubscription                       (optional)
Geo         Address · Geofence · GeocodingCache         (optional, PostGIS)
Mobile      PowerSyncDevice                            (optional)
Reliability AuditLog · OutboxEntry · IdempotencyRecord
System      ScheduledJob

Schemas in prisma/schema.prisma (always present) and prisma/features/<feature>.prisma (concatenated by bun run prepare:schema based on features.ts).

Seed data

bun run seed (runner: scripts/seed.ts, pure planner: src/core/setup/seed-plan.ts) upserts a login-ready demo dataset. Every record uses a deterministic id derived from a stable seed key so re-running the seed is fully idempotent — no duplicates accumulate.

Tenant

FieldValue
nameLenne Tech
sluglenne

Roles (per-tenant)

RoleisSystemPolicy
System Admintruemanage on all, no itemFilter — CASL full bypass
Adminfalsemanage on each project resource, scoped to $CURRENT_TENANT
UserfalseREAD on each project resource (tenant-scoped); UPDATE on User/UserProfile (user-scoped)

Users — three demo accounts (System Admin, Admin, User). Emails and passwords are printed by bun run seed to the terminal only; the Hub UI never surfaces them.

Passwords are hashed via better-auth/crypto hashPassword (scrypt) — the same function the sign-up flow uses, so POST /api/auth/sign-in/email works immediately after seeding. Each user gets a UserProfile row with a deterministic displayName and a TenantMember row (status ACTIVE).

The production guard (NODE_ENV=production check) is in the runner; the planner is a pure function with no I/O and is safe to call from tests.

Where to read more