Skip to content

Architecture

Frank Steiler edited this page Aug 6, 2026 · 24 revisions

Architecture

Overview

Cornerstone is a full-stack TypeScript web application deployed as a single Docker container. It serves a React SPA from a Fastify HTTP server, backed by SQLite for persistence.

                    +-------------------+
                    |   Reverse Proxy   |
                    |  (HTTPS/TLS)      |
                    +---------+---------+
                              |
                              | HTTP
                              v
               +--------------+---------------+
               |       Docker Container       |
               |                              |
               |  +-----------------------+   |
               |  |    Fastify Server     |   |
               |  |  (Node.js + ESM)      |   |
               |  |                       |   |
               |  |  /api/*   REST API    |   |
               |  |  /*       Static SPA  |   |
               |  +----+----------+-------+   |
               |       |          |           |
               |       v          v           |
               |   +-------+  +----------+   |
               |   |SQLite |  | React    |   |
               |   | (vol) |  | SPA      |   |
               |   +-------+  | (static) |   |
               |              +----------+   |
               +------------------------------+
                       |
                       | HTTP (proxy)
                       v
               +-----------------+
               | Paperless-ngx   |
               | (external)      |
               +-----------------+

Tech Stack

Layer Technology Version
Server Fastify 5.x
Client React 19.x
Routing (client) React Router 7.x
Database SQLite via better-sqlite3 --
ORM Drizzle ORM 0.38.x
Bundler (client) Webpack 5.x
Styling CSS Modules --
Testing (unit/integration) Jest (ts-jest) 30.x
Testing (E2E) Playwright 1.59.x
Language TypeScript ~6.0
Runtime Node.js 24 LTS
Container Docker (DHI Alpine) --

See individual ADRs for detailed rationale: ADR Index

Package Structure

The project uses npm workspaces with three packages:

Package Name Purpose
shared/ @cornerstone/shared TypeScript types shared between server and client
server/ @cornerstone/server Fastify REST API server
client/ @cornerstone/client React SPA

See ADR-007: Project Structure for the full directory layout.

Key Architectural Patterns

API Design

  • REST endpoints under /api/ prefix
  • Standard error response shape across all endpoints:
    {
      "error": {
        "code": "RESOURCE_NOT_FOUND",
        "message": "Human-readable description",
        "details": {}
      }
    }
  • Request validation via Fastify's JSON schema (AJV)
  • Pagination: Offset-based with page (1-indexed, default 1) and pageSize (default 25, max 100). Response includes { items: [...], pagination: { page, pageSize, totalItems, totalPages } }. See API Contract for full specification.
  • Filtering: Query parameters per field (e.g., ?status=in_progress&assignedUserId=...). Multiple filters are combined with AND logic. Text search via q parameter.
  • Sorting: sortBy and sortOrder (asc/desc) query parameters. One sort field per request.

Authentication

See ADR-010: Authentication Architecture for detailed rationale.

Two authentication flows:

  1. Local authentication: Email/password login for the initial admin account (setup flow) and as a fallback. Passwords hashed with argon2id (OWASP-recommended).
  2. OIDC authentication: OpenID Connect via openid-client v6 as the primary mechanism. Supports any standard provider (Keycloak, Auth0, Okta, Google, Azure AD, Authentik). Automatic user provisioning on first login.

Session management:

  • Server-side sessions stored in the sessions SQLite table
  • Session token: 256-bit crypto.randomBytes hex string, delivered as an HttpOnly cookie (cornerstone_session)
  • Cookie flags: HttpOnly=true, SameSite=Strict, Secure=true (configurable for dev)
  • 7-day lifetime (configurable via SESSION_DURATION env var)
  • Lazy cleanup of expired sessions (hourly interval)
  • Instant invalidation on user deactivation (all sessions deleted)

Route protection (Fastify hooks):

  • Authentication hook (authenticate): Global preHandler on all /api/* routes. Reads session cookie, validates session, loads user. Exempts public routes (health, auth/me, setup, login, OIDC endpoints).
  • Authorization decorator (requireRole('admin')): Route-level preHandler that checks user.role. Returns 403 for insufficient permissions.

Roles:

Role Permissions
Admin Full access: create, edit, delete everything + manage users
Member Create and edit work items, budget entries, comments

Frontend auth:

  • AuthContext React context with useAuth() hook
  • App initialization: calls GET /api/auth/me to determine state (setup required / login / authenticated)
  • Components use useAuth() for user info, login/logout/setup actions

Initial Setup Flow

On first launch with an empty database, the application requires initial admin setup before any users can authenticate. The setup flow ensures the first user account is created securely.

Client-side flow:

  1. App initialization calls GET /api/auth/me to determine the current state
  2. If setupRequired: true is returned (no users exist), the AuthGuard automatically redirects to /setup
  3. The user fills out the setup form (email, display name, password)
  4. On submission, the client calls POST /api/auth/setup with the account details
  5. After successful setup, the user is redirected to /login to sign in with the new credentials

Server-side protection:

  • The POST /api/auth/setup endpoint is only accessible when no users exist in the database
  • If users already exist, it returns 403 FORBIDDEN with error code SETUP_COMPLETE
  • The endpoint validates password strength (minimum 12 characters)
  • After creating the admin account, it returns 201 Created with the user object (no session created)

State detection via /api/auth/me:

{
  "user": null,
  "setupRequired": true,
  "oidcEnabled": false
}
  • setupRequired: true → client redirects to /setup
  • user: null (and setupRequired: false) → client redirects to /login
  • user: {...} → client renders the authenticated app

The setup page is only accessible when setupRequired is true. After setup completes, the endpoint returns 403 and the client-side setup route redirects to login.

Plugin Registration Order (Updated)

config -> errorHandler -> compress -> db -> auth -> routes -> static

The auth plugin registers after db (needs database access for session lookups) and before routes (to protect all route handlers by default).

Database

  • SQLite stored at /app/data/cornerstone.db inside the container (configurable via DATABASE_URL)
  • Volume-mounted for persistence across container restarts
  • WAL (Write-Ahead Logging) mode enabled at startup for better concurrent read performance
  • Schema managed via hand-written SQL migrations (see server/src/db/migrations/)
  • Drizzle ORM provides typed query building on top of better-sqlite3
  • snake_case column naming convention

Fastify Database Plugin

The database connection lifecycle is managed by a Fastify plugin (server/src/plugins/db.ts):

  1. Startup: Opens a better-sqlite3 connection, enables WAL mode, runs pending migrations, creates a Drizzle ORM instance
  2. Request handling: All routes access the database via fastify.db (Drizzle ORM instance with full schema type inference)
  3. Raw access: The underlying better-sqlite3 connection is available via fastify.db.$client when needed for pragmas or raw SQL
  4. Shutdown: The onClose hook closes the connection, flushing WAL to the main database file

The plugin is registered first in the Fastify app to guarantee the database is available before any route handler executes. If migrations fail, the plugin throws and the server does not start.

Error Handling

All API errors are handled by a centralized Fastify error handler plugin (ADR-009). The pattern works as follows:

  1. Route handlers and services throw AppError subclasses -- e.g., throw new NotFoundError('User not found'). They never construct ApiErrorResponse objects directly.
  2. The errorHandler plugin catches all errors and formats them into the standard ApiErrorResponse shape ({ error: { code, message, details? } }).
  3. AJV schema validation errors are handled automatically -- Fastify's built-in JSON schema validation produces validation errors that the plugin normalizes into VALIDATION_ERROR responses with field-level details.
  4. Unknown errors are sanitized in production -- Any error that is not an AppError or AJV validation error is returned as INTERNAL_ERROR (500). In production mode, the message is replaced with a generic string to prevent information leakage.

Error Class Hierarchy

Error
  +-- AppError (base: code, statusCode, message, details?)
        +-- NotFoundError       (NOT_FOUND, 404)
        +-- ValidationError     (VALIDATION_ERROR, 400)
        +-- UnauthorizedError   (UNAUTHORIZED, 401)
        +-- ForbiddenError      (FORBIDDEN, 403)
        +-- ConflictError       (CONFLICT, 409)

Error classes are defined in server/src/errors/AppError.ts. The ErrorCode type union is defined in @cornerstone/shared for cross-package type safety.

Logging

  • 4xx errors are logged at warn level (client mistakes, not server failures)
  • 5xx errors are logged at error level (genuine server failures for alerting)
  • All logging uses Fastify's request.log for request-scoped context

Plugin Registration Order

config -> errorHandler -> db -> routes

The error handler registers after config (to access NODE_ENV for production mode detection) but before routes (to catch errors from all route handlers).

Paperless-ngx Integration

See ADR-015: Paperless-ngx Integration Architecture for detailed rationale.

Communication pattern: All Paperless-ngx API requests are proxied through the Fastify server. The browser never communicates directly with Paperless-ngx. This keeps the API token secure on the server and avoids CORS issues.

Proxy endpoints under /api/paperless/ provide a curated subset of the Paperless-ngx API:

  • GET /api/paperless/status -- Check if Paperless-ngx is configured and reachable
  • GET /api/paperless/documents -- Search/browse documents (with pagination, filtering, sorting)
  • GET /api/paperless/documents/:id -- Single document metadata
  • GET /api/paperless/documents/:id/thumb -- Document thumbnail (binary passthrough)
  • GET /api/paperless/documents/:id/preview -- Document preview/PDF (binary passthrough)
  • GET /api/paperless/tags -- List all Paperless-ngx tags

Document linking uses a polymorphic document_links table that stores references between Cornerstone entities (work items, household items, invoices) and Paperless-ngx document IDs. Links are managed via:

  • POST /api/document-links -- Create a link
  • GET /api/document-links?entityType=...&entityId=... -- List links for an entity
  • DELETE /api/document-links/:id -- Remove a link

Configuration: Two environment variables control the integration:

Variable Default Description
PAPERLESS_URL (none) Base URL of the Paperless-ngx instance
PAPERLESS_API_TOKEN (none) API authentication token for Paperless-ngx

The integration is enabled when both variables are set. If either is missing, proxy endpoints return 503 SERVICE_UNAVAILABLE.

API version pinning: All upstream requests include Accept: application/json; version=5 to ensure a stable API contract.

Caching: No server-side cache in the initial implementation. With fewer than 5 users, request volume is minimal. An in-memory LRU cache can be added later without changing the API contract.

Dashboard & User Preferences

See ADR-017: Chart Library for the chart library decision.

Dashboard architecture:

The dashboard (/) is the default landing page for authenticated users. It aggregates data from existing API endpoints into a grid of health cards. No dedicated dashboard data endpoint exists -- the client fetches data in parallel from existing endpoints (GET /api/budget/overview, GET /api/timeline, GET /api/budget-sources, GET /api/subsidy-programs, GET /api/invoices?status=pending) and renders each card independently.

Design rationale:

  • Client-side aggregation: Each dashboard card fetches from an existing endpoint. This avoids introducing a new server-side aggregation endpoint that would couple all card data into a single request. Individual fetches enable parallel loading, independent error states per card, and reuse of existing API infrastructure.
  • Recharts for charts: SVG-based charting library (donut charts, bar charts) consistent with the existing custom SVG Gantt chart approach. See ADR-017.
  • Card visibility: Users can show/hide individual dashboard cards. Visibility state is persisted via the user preferences API (dashboard.hiddenCards preference key).

User preferences architecture:

Per-user key-value storage for UI settings (theme, dashboard card visibility, future preferences). Stored in the user_preferences SQLite table with a UNIQUE(user_id, key) constraint for upsert semantics.

  • Key format: Dot-notation namespacing (e.g., dashboard.hiddenCards, theme)
  • Value format: Opaque strings; complex values (arrays, objects) are JSON-encoded by the client
  • Open schema: The API accepts any string key without server-side validation, allowing the client to evolve preference keys without backend changes
  • ThemeContext migration: On first load, ThemeContext checks for a theme preference via the API. If none exists but localStorage has a legacy theme value, it migrates the value to the preferences API and clears localStorage.

Multi-step wizard state: tier factories

The report wizard (client/src/pages/ReportWizardPage/wizardReducer.ts) is the reference implementation of the convention every multi-step flow in the client should follow. Its state is partitioned into tiers (selection, sources, report, content, settings, nav), each with a freshXTier() factory, and every reducer case builds its next state by spreading the relevant factories plus explicit field writes — never by an ad-hoc spread of individually-cleared fields.

The point is the forcing function: adding a field to a tier's TypeScript interface is a compile error in that tier's factory, so the field cannot be silently forgotten by a reset path. This convention exists because three separate defects (#1943, #1946, and the hazard #1947 was raised to close) were all the same bug — wizard state surviving a step it should not have survived. Hand-rolled resets are what let that happen.

Which state belongs in which tier is decided by its reset lifecycle, not by what it feels like. Report column visibility (#1973) lives on ContentTier alongside overrides purely because it must reset when the use case or the source changes; it is not otherwise a "content edit".

One deliberate opt-out exists, and it is the limit. DISCARD_EDITS spreads freshContentTier() and then writes hiddenColumns: state.hiddenColumns back, because column visibility is a presentation choice rather than a discardable text edit. It is commented at the call site and pinned by its own reducer test. If a second field ever needs the same treatment, split the tier — introduce a presentation tier that SELECT_USE_CASE/SELECT_SOURCE reset but DISCARD_EDITS does not — rather than accumulating per-field exceptions. Two or more opt-outs mean the tier boundary is in the wrong place, and each additional one erodes the compile-time guarantee the convention exists to provide.

Per-run wizard state is not a user preference. Column visibility is deliberately not routed through the server-persisted preferences API described above: the appropriate column set varies per recipient, so a sticky per-user value would be wrong more often than right and wrong invisibly. It also keeps a network dependency (and its failure modes) out of a wizard step that has none. Reach for user_preferences for stable per-user reading preferences; reach for reducer state for per-document editorial decisions.

Construction Diary (Bautagebuch)

See ADR-020: Construction Diary Architecture for detailed rationale.

The construction diary is a project-level documentation tool that combines manual entries (daily logs, site visits, deliveries, issues, notes) with automatic system events (status changes, budget breaches, schedule impacts). Key design decisions:

  • Single diary_entries table with a JSON metadata column for type-specific fields. 11 entry types (5 manual + 6 automatic) share one table. Metadata validated at the application layer via TypeScript type guards.
  • Polymorphic source references: Automatic entries link to their source entity via source_entity_type + source_entity_id (same pattern as document_links and photos). No FK constraint — diary entries intentionally survive source entity deletion.
  • Fire-and-forget auto events: The diaryEventService is called from existing services after state changes. It never throws or blocks the caller. This ensures diary logging never breaks core functionality.
  • Signatures stored inline: Base64 data URLs in the metadata JSON (max 500 KB). Simple storage for small, write-once artifacts.
  • Photo attachments via existing infrastructure: Uses the photos table with entity_type = 'diary_entry'. No new photo endpoints.
  • Immutability rules: Manual entries are editable; automatic entries are completely immutable (cannot be updated or deleted).
  • PDF export: Server-side PDF generation with embedded photos and signatures. Pure JavaScript library (no native dependencies).

Internationalization (i18n)

See ADR-021: Internationalization Architecture for detailed rationale.

Library: react-i18next (client-side only). Translations are statically bundled -- no runtime HTTP fetches for translation files.

Supported locales: en (English), de (German). Fallback language: en.

Locale detection order:

  1. Server preference (user_preferences API, key locale)
  2. localStorage key locale (instant hydration)
  3. Browser navigator.language (mapped: de* -> de, else -> en)
  4. Fallback: en

Locale preference values: 'en' | 'de' | 'system' (same tri-state pattern as theme preference).

Translation file structure:

client/src/i18n/
  index.ts              # i18next initialization
  en/                   # One JSON file per namespace
    common.json, auth.json, dashboard.json, workItems.json,
    householdItems.json, budget.json, schedule.json, diary.json,
    documents.json, settings.json, errors.json
  de/                   # Same structure

Key conventions: Flat dot-notation within each namespace. Prefixes: nav.*, button.*, label.*, placeholder.*, status.*, toast.*, heading.*, empty.*, confirm.*.

Currency: Deployment-level setting via CURRENCY environment variable (default: EUR). Exposed to the client via GET /api/config (public, no auth). All users in one instance share the same currency.

Formatter localization: All formatters in client/src/lib/formatters.ts accept locale and currency parameters. A useFormatters() hook wraps them with the current locale and currency from LocaleContext.

Error translation: Server error messages remain English. The client translates ErrorCode values to display messages using the errors translation namespace.

Context tree:

<BrowserRouter>
  <ThemeProvider>
    <LocaleProvider>     <!-- new: mirrors ThemeProvider pattern -->
      <ToastProvider>
        <AuthProvider>
          <ThemeServerSync />
          <LocaleServerSync />  <!-- new: syncs locale on auth -->
          <Routes>...</Routes>
        </AuthProvider>
      </ToastProvider>
    </LocaleProvider>
  </ThemeProvider>
</BrowserRouter>

Areas & Trades (Structured Dimensions)

See ADR-028: Areas & Trades for detailed rationale.

EPIC-18 replaces the generic tagging system with two purpose-built dimensions:

Areas — hierarchical spatial organization of the construction project. Areas represent physical locations (e.g., "Ground Floor > Kitchen > Island") using a self-referencing parent_id with arbitrary nesting depth. Each work item and household item belongs to at most one area (M:1, nullable). Areas enable cost breakdowns by location ("how much did the kitchen cost?").

Trades — flat craft/skill classification. Trades represent the kind of work performed (e.g., "Plumbing", "Electrical"). Trades are assigned to vendors (not directly to work items). A work item's trade is derived transitively via work_items.assigned_vendor_id -> vendors.trade_id. This avoids denormalization while enabling trade-based cost analysis.

Vendor assignment: Work items support direct vendor assignment via assigned_vendor_id, mutually exclusive with assigned_user_id (enforced by CHECK constraint). This models the distinction: either you do the work yourself, or a contractor does.

Removed: tags, work_item_tags, household_item_tags tables. The vendors.specialty free-text field is replaced by vendors.trade_id FK. The household_items.room free-text field is replaced by household_items.area_id FK.

Static Asset Serving

  • In production, Fastify serves the Webpack-built client from client/dist/
  • SPA fallback: any non-/api/ route serves index.html
  • In development, Webpack dev server (port 5173) proxies /api/* to Fastify (port 3000)

Configuration

All configuration is via environment variables:

Core

Variable Default Description
PORT 3000 Server port
HOST 0.0.0.0 Server bind address
DATABASE_URL /app/data/cornerstone.db SQLite database file path
LOG_LEVEL info Pino log level
NODE_ENV production Environment (production/development)
CURRENCY EUR ISO 4217 currency code for display (e.g., EUR, USD, CHF)

Authentication & Sessions

Variable Default Description
SESSION_DURATION 604800 Session lifetime in seconds (default: 7 days)
SECURE_COOKIES true Set Secure flag on cookies; set to false for local dev without TLS
TRUST_PROXY false Trust X-Forwarded-* headers from a reverse proxy
AUTH_RATE_LIMIT_MAX 20 Login endpoint rate limit: max requests per IP per window (positive integer)
AUTH_RATE_LIMIT_WINDOW 15 minutes Login endpoint rate limit: time window (ms-library format, e.g. 15 minutes, 1h)
OIDC_ISSUER (none) OIDC provider issuer URL
OIDC_CLIENT_ID (none) OIDC client ID
OIDC_CLIENT_SECRET (none) OIDC client secret
EXTERNAL_URL (none) Public base URL (e.g., https://cornerstone.example.com); OIDC callback origin

OIDC is enabled when OIDC_ISSUER, OIDC_CLIENT_ID, and OIDC_CLIENT_SECRET are all set — three variables, not four (server/src/plugins/config.ts, oidcEnabled). If any of the three is missing, the OIDC endpoints are unavailable.

The callback URL is not configured through a dedicated environment variable — there is no OIDC_REDIRECT_URI, and the server never reads one. The redirect URI sent to the provider is derived per request as <EXTERNAL_URL>/api/auth/oidc/callback, falling back to the incoming request's own origin (request.protocol + request.host) when EXTERNAL_URL is unset. Behind a reverse proxy, set EXTERNAL_URL: both halves of the fallback are gated on TRUST_PROXYrequest.protocol reads X-Forwarded-Proto and request.host reads X-Forwarded-Host only when TRUST_PROXY=true, so with it unset the fallback yields the container's own internal scheme and host, not the public one. A correct scheme is therefore not sufficient to make the fallback safe. The redirect URI registered with the OIDC provider must match the derived value exactly.

The login rate limit (POST /api/auth/login) is keyed on the client address after normalization — IPv4 addresses key per-address, IPv4-mapped IPv6 addresses are unwrapped to their dotted IPv4 form, and all other IPv6 addresses are truncated to their /64 network prefix so that an entire IPv6 allocation shares one bucket. Behind a reverse proxy, TRUST_PROXY=true is required for the real client address to be resolved — otherwise every request appears to come from the proxy and the limit is effectively shared across all clients. The first-user setup endpoint (POST /api/auth/setup) has a separate, fixed limit of 5 requests per 15 minutes and is not configurable.

Normalization is @fastify/rate-limit's normalizeIP. The invariant is that the rate-limit key is always normalizeIP-normalized, at the same subnet width the plugin is configured with. rateLimitPlugin satisfies it with an explicit exported generator — rateLimitKeyGenerator(request) => normalizeIP(request.ip ?? 'unknown', IPV6_SUBNET) — and passes that same IPV6_SUBNET constant (64) as the plugin's ipv6Subnet option, so the two cannot drift. The override is required, not stylistic: request.ip is typed string but is genuinely null when the socket has no address metadata (and normalizeIP dereferences its argument, so an unguarded call turns every rate-limited endpoint into a 500), and the library applies its own normalization only when keyGenerator is the exact defaultKeyGenerator reference — any custom generator is invoked without a subnet argument and must therefore call normalizeIP itself. A generator that returns request.ip verbatim skips the /64 truncation and lets an attacker rotate addresses within their own prefix to bypass the limit; that regression was CVE-2026-15144 (issue #1995). Hand-rolling X-Forwarded-For / X-Real-IP parsing inside the generator is also unnecessary, since TRUST_PROXY already makes request.ip reflect those headers. See API Contract → Rate-Limit Key for the per-form key table and the full list of invariants any change to rateLimitKeyGenerator must preserve.

Paperless-ngx

Variable Default Description
PAPERLESS_URL (none) Base URL of the Paperless-ngx instance (e.g., http://paperless:8000)
PAPERLESS_API_TOKEN (none) API authentication token for Paperless-ngx (obtain from Paperless-ngx admin panel)

Paperless-ngx integration is enabled when both PAPERLESS_URL and PAPERLESS_API_TOKEN are set. If either is missing, all /api/paperless/* endpoints return 503.

Backup & Restore

Variable Default Description
BACKUP_DIR (none) Backup destination directory (must not be app data directory or its subdirectory)
BACKUP_CADENCE (none) Cron expression for automatic backups (e.g., 0 2 * * * for daily at 2 AM)
BACKUP_RETENTION (none) Maximum number of backup archives to retain (oldest deleted when exceeded)

Backup functionality is enabled when BACKUP_DIR is set. If unset, all /api/backups/* endpoints return 503. Backups are .tar.gz archives of the entire app data directory, using SQLite's backup() API for database consistency. Automatic scheduling uses node-cron (in-process, no external cron dependency). Restore replaces the app data directory and exits the process; Docker's restart policy brings it back up.

Diary Drafts

Variable Default Description
DIARY_DRAFT_RETENTION_DAYS 30 Number of days a draft diary entry can sit untouched before the daily orphan-cleanup job hard-deletes it (and its photos). Set to 0 to disable cleanup.

The orphan cleanup job is a node-cron task that runs daily at 03:00 server time (in-process, sharing the scheduler pattern with backupService). It deletes diary entries where status = 'draft' AND updated_at < datetime('now', '-N days'). Photo cascade uses the existing deletePhotosForEntity path. See ADR-022 for the full draft model.

Deployment

  • Single Docker container built from a multi-stage Dockerfile
  • Production image uses DHI (Docker Hardened Images) Alpine with a non-root user
  • SQLite data persisted via a Docker volume at /app/data/
  • HTTPS handled by an upstream reverse proxy (nginx, Traefik, Caddy, etc.)
  • Health check endpoint: GET /api/health

Development Environment Constraints

Sandbox Memory Limitation (4 GB RAM)

The development sandbox environment has only 4 GB of total RAM. This is insufficient to run the full Jest test suite (34+ test files across 3 workspaces) with default parallelism settings. Each jsdom worker loads React, react-dom, react-router, and testing-library, consuming approximately 200--300 MB per worker. With Jest's default worker count (number of CPU cores minus one), the suite exhausts memory and the Node.js process is killed by the OOM reaper.

Mitigations Applied

The following flags have been applied to all three test scripts (test, test:watch, test:coverage) in the root package.json:

Flag Value Purpose
--max-old-space-size 2048 Caps the V8 heap at 2 GB (Node.js flag), preventing a single process from consuming all available memory
--maxWorkers 2 Limits Jest to 2 parallel workers instead of auto-detecting CPU cores
--workerIdleMemoryLimit 300MB Recycles any Jest worker whose memory exceeds 300 MB, preventing unbounded heap growth from jsdom leaks

Example of the current test script:

node --max-old-space-size=2048 --experimental-vm-modules node_modules/.bin/jest --maxWorkers=2 --workerIdleMemoryLimit=300MB

These flags do not affect production -- they only apply to the development test runner. The --experimental-vm-modules flag is required for ESM support in Jest and is unrelated to the memory constraint.

Impact

  • Slower test runs: With only 2 workers, the full suite runs sequentially across fewer processes. Wall-clock time is roughly 2-3x longer than it would be with --maxWorkers=auto on a machine with 4+ cores.
  • Worker recycling overhead: The --workerIdleMemoryLimit flag causes Jest to terminate and respawn workers mid-run, adding per-recycle overhead (approximately 1-2 seconds each time).
  • No functional impact: All tests produce the same results regardless of worker count or memory limits. Coverage numbers are unaffected.

Reversal Plan

When the sandbox memory increases (8 GB or more), revert the memory-constrained test scripts in the root package.json to their ideal configuration:

Current (constrained):

"test": "node --max-old-space-size=2048 --experimental-vm-modules node_modules/.bin/jest --maxWorkers=2 --workerIdleMemoryLimit=300MB",
"test:watch": "node --max-old-space-size=2048 --experimental-vm-modules node_modules/.bin/jest --watch --maxWorkers=2 --workerIdleMemoryLimit=300MB",
"test:coverage": "node --max-old-space-size=2048 --experimental-vm-modules node_modules/.bin/jest --coverage --maxWorkers=2 --workerIdleMemoryLimit=300MB"

Target (unconstrained):

"test": "node --experimental-vm-modules node_modules/.bin/jest",
"test:watch": "node --experimental-vm-modules node_modules/.bin/jest --watch",
"test:coverage": "node --experimental-vm-modules node_modules/.bin/jest --coverage"

Changes to make:

  1. Remove --max-old-space-size=2048 from all three scripts (let V8 use its default heap limit)
  2. Remove --maxWorkers=2 from all three scripts (let Jest auto-detect the optimal worker count)
  3. Remove --workerIdleMemoryLimit=300MB from all three scripts (or raise to 512MB if jsdom memory leaks are still a concern)
  4. Keep --experimental-vm-modules -- this is required for ESM support and is unrelated to memory

Schema & API Contract

The database schema and API contract evolve incrementally as each epic is implemented. See:

  • Schema -- current database schema documentation
  • API Contract -- current REST API specification

Deviation Log

This log records where the page drifted from the implementation and how the drift was resolved. It carries two payloads: what is true now, and what we previously believed and how we got it wrong. Only the second justifies keeping a log instead of a changelog.

Corrections are made forward. The Deviation cell of an existing row — the observation — is immutable. Once entries can be silently rewritten, no reader can tell whether a claim was ever made, and an instrument built to catch recurring drift starts erasing its own misses. When a row turns out to be wrong, append to its Resolution cell, leading with Correction to the observation above:, and name the wrong claim, state what is actually true, and cite the source. A wholly spurious entry is withdrawn in its Resolution, never deleted. Typographical fixes and cell re-padding are editable in place.

Date Page Section Deviation Resolution
2026-08-04 Environment Variables (Auth) The login rate-limit paragraph said the limit "is keyed on the client IP", presenting the raw address as the bucket key. That was never accurate for IPv6 under @fastify/rate-limit 11.2.0, whose default keyGenerator normalizes request.ip (IPv4-mapped unwrapping plus /64 truncation). The wording also invited the CVE-2026-15144 regression (issue #1995): a custom keyGenerator returning request.ip verbatim reads as a faithful implementation of the documented behaviour while silently removing the /64 grouping that prevents bypass via address rotation within an attacker's own prefix. Wiki was outdated and misleading; code (after PR #1998, refined in PR #1999) is correct. Rewrote the paragraph to describe the normalized key (IPv4 per-address, IPv4-mapped unwrapped, other IPv6 truncated to the /64 prefix), noted that hand-rolled X-Forwarded-For/X-Real-IP parsing is redundant under TRUST_PROXY, and cross-linked the API Contract's "Rate-Limit Key" table. The first pass of this entry stated that rateLimitPlugin intentionally defines no keyGenerator; PR #1999 invalidated that, since an explicit override is required — request.ip is typed string but can be null at runtime (normalizeIP dereferences it, producing a 500), and the library applies its own normalization only for the exact defaultKeyGenerator reference, so a custom generator must call normalizeIP itself. The paragraph now documents rateLimitKeyGenerator (normalizeIP(request.ip ?? 'unknown', IPV6_SUBNET), with the same IPV6_SUBNET passed as the plugin's ipv6Subnet so the two cannot drift) and why the override exists. Source of truth: normalizeIP/defaultKeyGenerator and the generator-identity gate in @fastify/rate-limit 11.2.0 (defaultIPv6Subnet = 64), and server/src/plugins/rateLimitPlugin.ts. This Deviation Log table did not previously exist on this page and was created by this entry.
2026-08-05 Key Architectural Patterns The wizard tier-factory convention was never documented anywhere. The freshXTier()-spread rule in wizardReducer.ts — adding a field to a tier interface is a compile error in its factory, so no reset path can silently forget it — was introduced in #1947 to close a hazard that had already produced three defects (#1943, #1946, and the one #1947 was raised for), but existed only as code comments. A reviewer on PR #2010 (#1973) had to reconstruct it from the reducer to judge whether DISCARD_EDITS's new hiddenColumns: state.hiddenColumns write was drift or a deliberate exception. Wiki was silent; code is correct. Added a "Multi-step wizard state: tier factories" subsection stating the convention, the rule that tier membership is decided by reset lifecycle rather than by topic, the single deliberate DISCARD_EDITS opt-out with the explicit limit that a second opt-out must trigger a tier split rather than another exception, and the per-run-state vs user_preferences boundary that R5 of #1973 settled.
2026-08-06 Authentication & Sessions The Authentication & Sessions table documented an OIDC_REDIRECT_URI variable that server/src/plugins/config.ts never reads, and the sentence below it claimed "OIDC is enabled when all four OIDC variables are set". The gate is three variables (config.ts:142: oidcEnabled = !!(oidcIssuer && oidcClientId && oidcClientSecret)), and the callback URL is not configurable at all — it is derived per request from EXTERNAL_URL, which the table did not list. An operator would set a variable that has no effect, or suspect a misconfiguration after correctly setting the three real ones. Wiki was outdated; the code is correct (issue #1992, the tracked follow-up flagged in the API Contract's Deviation Log by PR #1989). Removed the OIDC_REDIRECT_URI row and put EXTERNAL_URL in its place, restated the gate as three variables, and added a paragraph explaining that the redirect URI is derived per request as <EXTERNAL_URL>/api/auth/oidc/callback, falling back to request.protocol + request.host when EXTERNAL_URL is unset (and that those honour X-Forwarded-* only under TRUST_PROXY=true). Source of truth: server/src/plugins/config.ts:137-142 and server/src/routes/oidc.ts:45. Wiki-only change; no production code touched. The mirrored correction on the API Contract page is recorded in that page's own Deviation Log.

Clone this wiki locally