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
| Layer | Choice | Why |
|---|---|---|
| Runtime | Bun 1.x (Node 22 fallback) | TypeScript-first, fast cold start, native test runner |
| Framework | NestJS 11 | DI, decorators, modular, runs on Bun |
| Language | TypeScript 5.9+ strict | No implicit any, no @ts-ignore |
| ORM | Prisma 7 (driver-adapter mode) | Typed, migrations, Postgres-first |
| DB | Postgres 18 | RLS, JSONB, FTS, LISTEN/NOTIFY, pg_uuidv7 |
| Auth | Better-Auth 1.6 | Email/PW, OAuth, 2FA, Passkey, sessions, JWT — one stack |
| AuthZ | CASL 6 + DB-persisted rules | Industry standard; we own the persistence, not the engine |
| Validation | Zod 4 | Single SoT for DTOs and OpenAPI schemas |
| API | REST + OpenAPI 3.1 + Scalar UI | No GraphQL by design |
| Storage | S3 / Local / Postgres | Three adapters, one interface |
| Nodemailer + Brevo | SMTP for dev, Brevo for prod | |
| Webhooks | BullMQ + HMAC-SHA256 | Standard-Webhooks spec, see webhook-spec.md |
| Search | Postgres FTS (tsvector + GIN) | No external infra |
| Realtime | Postgres LISTEN/NOTIFY + Socket.IO + Redis adapter | Socket.IO cross-pod fanout via Redis when REDIS_URL set |
| Mobile sync | PowerSync + SQLite client | Offline-first |
| Encryption | AES-256-GCM via @47ng/cloak | Field-level, key-versioned |
| Geo | PostGIS + Mapbox/Nominatim/Google adapter | Standard Postgres-Geo, GeoJSON I/O |
| Jobs | BullMQ + Redis | Repeatable crons + ad-hoc queue; in-memory fallback when REDIS_URL unset |
| Rate limit | @nestjs/throttler + Postgres store | Multi-instance |
| Observability | OpenTelemetry (OTLP) + Pino | Traces + metrics + correlated logs |
| Errors | RFC 7807 Problem Details | application/problem+json |
| IDs | UUID v7 | Time-sorted, B-tree friendly |
| Tests | Vitest 4 (Bun test for perf only) | Bigger plugin ecosystem |
| Lint/format | oxlint + oxfmt | Rust-based, fast |
| API style | REST | No GraphQL, no subscriptions outside Socket.IO |
| Dev-Portal SPA | React 19 + react-router-dom 7 | Single SPA, every dev/admin/errors/openapi page |
| Dev-Portal UI | shadcn/ui (Radix) + Tailwind CSS 4 + lucide-react + sonner | Vendored primitives, CSS-first @theme, tree-shaken icons, toasts |
Out of scope (do not add)
| Removed | Reason |
|---|---|
| GraphQL / Apollo | REST + OpenAPI is sufficient and halves complexity |
Legacy CoreAuthService | Better-Auth covers everything |
| Vendor-Mode | Workaround for code comprehension; greenfield doesn't need it |
| Mailjet | Brevo covers all use cases |
| Mongoose / MongoDB / GridFS | Prisma + Postgres + S3 storage |
@UnifiedField decorator | Prisma + Zod are the SoT |
Self-built @Restricted/@Roles | Replaced by DB-configurable CASL permissions |
process()-style raw pipelines | Replaced 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:
- 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. - Repository layer (Prisma
WHERE) —accessibleBy(ability, 'read')produces a Prisma filter that isAND-merged into every read query. This is the layer that ensures afindManynever returns rows the user can't see — even if the handler forgets to filter. - 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)
itemFilteris a JSON filter expression (Directus DSL —_eq,_in,_and,_or, etc.) with variable markers ($CURRENT_USER,$CURRENT_TENANT,$NOW) resolved at request time.fieldsis a string-array allowlist.fields = []means "no field-level restriction" — seeOPEN_QUESTIONS.mdfor the rationale (CASL cannot represent "deny every field" in a single rule; the deny case is expressed by simply not granting the action).presetsare default values applied onCREATE.validationis a JSON schema applied onCREATE/UPDATE.
Administrator and Public are system roles — Administrator bypasses
every check; Public applies to unauthenticated requests.
Storage adapter & default rules — PrismaPermissionStorage
(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:
@Can(action, subject)— gated by the CASL ability layer above.@Public("<reason>")— explicit consent that the route is anonymous-by-design (with the rationale at the decoration site).- A path on the runtime allowlist (
PUBLIC_PREFIXES/PUBLIC_EXACTinsrc/core/auth/jwt-middleware.ts,EXEMPT_*insrc/core/multi-tenancy/tenant-guard.ts).
The build-time CI gate
(tests/stories/route-gating-audit.story.test.ts →
src/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/:
| File | Role |
|---|---|
zod-to-openapi.ts | Pure 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.ts | Boot-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
| Surface | Mechanism |
|---|---|
All gated routes (/api/*, /admin/*, /hub/*) | POST /api/auth/organization/set-active → session.activeOrganizationId. Stray x-tenant-id headers are ignored. |
| Hub HTML (no session org yet) | resolveHubOperatorTenantId may pick a default membership for shell render only. |
| Bootstrap | GET /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 layer —
TenantInterceptor+ CASL$CURRENT_TENANTfrom the resolved org id (AsyncLocalStorage). - DB layer — Postgres RLS via
SET LOCAL app.tenant_idinPrismaService.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
| Route | Purpose | Auth | Tenant scope |
|---|---|---|---|
GET /api/me/tenants | List memberships for the caller | required | exempt |
POST /api/tenants | Create org + owner membership | required | exempt |
/api/* (domain) | CRUD | required | session active org |
/admin/*, /hub/* | Operator tools | required | header 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:
| Subsystem | Path | Purpose |
|---|---|---|
| Webhooks | src/core/webhooks/ | Outbound HMAC-signed events; see webhook-spec.md |
| Realtime | src/core/realtime/ | LISTEN/NOTIFY → Socket.IO, permission-aware rooms, dev-only inspector state with PII-masked event ringbuffer |
| MCP | src/core/mcp/ | Model Context Protocol server, OAuth 2.1 (PKCE) |
| Outbox | src/core/outbox/ | Reliable event publishing (DB-write + dispatch in one tx) |
| Audit | src/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 |
| Jobs | src/core/jobs/ | BullMQ job queue + in-memory fallback |
| Auth | src/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) |
| Idempotency | src/core/idempotency/ | Stripe-style Idempotency-Key header; Postgres-backed (idempotency_records) with a 24h cleanup cron pruning expired rows |
| Concurrency | src/core/concurrency/ | ETag / If-Match optimistic-lock |
| Encryption | src/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 |
| Files | src/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 |
| Geo | src/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:
| Driver | Path | Used when |
|---|---|---|
SmtpEmailDriver | src/core/email/drivers/smtp.driver.ts | features.email.provider === "smtp" and SMTP_HOST is set. Wraps Nodemailer with a connection pool + 10 s timeouts. |
BrevoEmailDriver | src/core/email/drivers/brevo.driver.ts | features.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. |
LogOnlyEmailDriver | src/core/email/email.module.ts | features.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:
SmtpEmailDriversetsallowInternalNetworkInterfaces: truebecause Nodemailer ≥ 7 blocks loopback / private addresses by default (SSRF guard).- The Mailpit container is started with
--smtp-disable-rdnsbecause 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
- Create an API key at https://app.brevo.com/settings/keys/api.
- Set
BREVO_API_KEY=xkeysib-...in.env. - Either flip
FEATURE_EMAIL_PROVIDER=brevoto make Brevo the primary, or keepprovider=smtpand use Brevo only for templates viasendTemplate({ brevoTemplateId }). - 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.
| Aspect | Path | Purpose |
|---|---|---|
| Shell renderer (planner) | src/core/dx/dev-portal-shell.ts | Pure function: title + script URL + token CSS URL → static HTML5 skeleton with <div id="root"> |
| SPA source tree | src/core/dx/clients/ | Browser-only: main.tsx (entry), App.tsx (router), layout/, pages/, components/, lib/, styles/ |
| Layout shell | src/core/dx/clients/layout/AdminShell.tsx + nav.ts + icons.tsx | Sidebar + header + SVG icons + active-state highlight |
| Pages | src/core/dx/clients/pages/ | One component per route: HubLoginPage (/, Better-Auth email/password), HubLandingPage (/hub), FeaturesPage, … — each lazy-loaded via React.lazy |
| Hub portal auth | src/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 primitives | src/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 components | src/core/dx/clients/components/ | JsonViewer (reused by /errors, /openapi, /hub/postgrest-parse), PageState (Loading / Error / Empty / StatTile helpers), Sparkline (Webhook-Inspector trends) |
| Icons | src/core/dx/clients/layout/icons.tsx | Sidebar + page icons via lucide-react — single import, tree-shaken to ~3 KB gzipped, consistent stroke-width 1.75 |
| Toasts | sonner | Notification primitive mounted in main.tsx + the <Toaster> component, used for save / delete confirmations across /hub/* and /admin/* |
| Styling stack | src/core/dx/clients/styles/globals.css | Tailwind 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 tokens | src/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 script | scripts/build-dev-portal.ts | Bun.build({ target: "browser", splitting: true, minify: true }) → dist/dev-portal/ |
/api/hub/* JSON sidecars | hub.controller.ts | dashboard.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 endpoints | hub.controller.ts + src/core/email/email-builder.ts | preview.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 endpoints | hub.controller.ts + migrations/migrations.service.ts | deploy, apply-one, dry-run, retry, create, apply-draft, draft/:name (DELETE) — Postgres advisory-lock-gated, 404 outside development |
/api/admin/* JSON sidecars | admin-spa.controller.ts | permissions/test.json, webhooks.json, realtime.json, realtime/channels.json, audit.json, search.json |
/api/admin/* POST actions | admin-spa.controller.ts | realtime/sockets/:id/disconnect, realtime/sockets/:id/send, realtime/events/replay — all dev-only, all 404 in production |
| Static asset endpoint | GET /hub/static/:filename | 404 outside development; allow-list filename, MIME-detect, stream from dist/dev-portal/ |
| Catch-all | GET /*splat | Returns the SPA shell so client-side routes work without a server change |
| Server tsconfig | tsconfig.json (excludes src/core/dx/clients/**) | Server build never sees browser code |
| Client tsconfig | tsconfig.client.json | jsx: "react-jsx", lib: ["ES2022","DOM","DOM.Iterable"], types: [] |
Build & dev-loop
bun run build:dev-portalproducesdist/dev-portal/main.js(+ code-split chunks +main.css+tokens.css). The Tailwind oxide compiler runs throughbun-plugin-tailwindso the CSS bundle only contains classes the SPA actually references — Tailwind purge keepsmain.csslean while shadcn primitives stay first-class. Bundle budget: ≤ 1.2 MB total (all chunks) for the Base-SPA (no Monaco, no TipTap).bun run devruns an awaited initial portal build before the API child spawns, then startsbun run build:dev-portal --watchfor incremental rebuilds (~80 ms warm). This eliminates the startup race where a request to/hub/static/main.jscould hit a missing bundle.bun run setupwrites.env, optionally bootstraps Postgres/Redis + schema/migrations/seed (--skip-bootstrapto opt out), and builds the SPA once so/hub/static/main.jsexists 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@themebridge instyles/globals.cssto the brand-aware tokens instyles/tokens.css. The full inventory of available primitives + variants lives in shadcn/ui primitives undercomponents/ui/(no separate showcase route). - No
process.env.*/ Node imports. This tree is browser-only;tsconfig.client.jsonexcludes Node types so this fails at compile time. - No server-rendered HTML left. Every
Controllerreturning 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)
| Layer | Mechanism |
|---|---|
| Network | TLS via reverse proxy, HSTS |
| Boot | ENV validation (Zod), assertCookiesProductionSafe(), fail-fast |
| CORS | Auto-derived from BASE_URL/APP_URL; opt-in allowedOrigins[] |
| Cookies | httpOnly, Secure, SameSite=Lax (default), signed |
| Auth | Better-Auth (sessions + JWT), 2FA, Passkey, rate-limit, brute-force lockout |
| API keys | argon2id hash, scopes, expiry, revocation |
| AuthZ | CASL + DB rules, field-level + item-level, presets, validation |
| Output | 4-stage pipeline, secret safety net |
| Field encryption | AES-256-GCM, key versioning, optional blind-index |
| Webhooks | HMAC-SHA256, replay protection, auto-disable |
| Realtime | Permission-aware rooms, auth handshake |
| Mobile sync | Sync rules ⊆ READ permissions, JWT audience validation |
| Tenant isolation | App layer + RLS |
| Input | Zod pipe, mime-magic-byte for files |
| DB | RLS, FK with ON DELETE, soft-delete |
| Files | Mime allowlist, magic-byte, path-traversal guards, signed URLs |
| Logging | Pino + OTel, W3C trace context, no PII in logs |
| Rate limit | @nestjs/throttler + Postgres store, multi-window |
| Headers | Helmet (HSTS, X-Content-Type-Options, X-Frame-Options, CSP) |
| Idempotency | Idempotency-Key header, 24h cache |
| Concurrency | ETag / If-Match |
| Errors | RFC 7807 (application/problem+json) |
| Secrets | Never in code; Better-Auth secret 32+ chars; rotation supported |
| Dependencies | bun 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
| Field | Value |
|---|---|
name | Lenne Tech |
slug | lenne |
Roles (per-tenant)
| Role | isSystem | Policy |
|---|---|---|
System Admin | true | manage on all, no itemFilter — CASL full bypass |
Admin | false | manage on each project resource, scoped to $CURRENT_TENANT |
User | false | READ 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
- Coding conventions →
code-guidelines.md - How to contribute →
../CONTRIBUTING.md - Project-specific code →
customization-guide.md - Public-surface stability →
api-stability-promise.md - Upstream PR workflow →
core-contribution-guide.md - Template sync →
template-update-workflow.md - Webhook contract →
webhook-spec.md - Working with AI agents →
working-with-ai-agents.md