-
Notifications
You must be signed in to change notification settings - Fork 3
Architecture
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) |
+-----------------+
| 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
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.
- 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) andpageSize(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 viaqparameter. -
Sorting:
sortByandsortOrder(asc/desc) query parameters. One sort field per request.
See ADR-010: Authentication Architecture for detailed rationale.
Two authentication flows:
- Local authentication: Email/password login for the initial admin account (setup flow) and as a fallback. Passwords hashed with argon2id (OWASP-recommended).
-
OIDC authentication: OpenID Connect via
openid-clientv6 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
sessionsSQLite table - Session token: 256-bit
crypto.randomByteshex 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_DURATIONenv var) - Lazy cleanup of expired sessions (hourly interval)
- Instant invalidation on user deactivation (all sessions deleted)
Route protection (Fastify hooks):
-
Authentication hook (
authenticate): GlobalpreHandleron 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-levelpreHandlerthat checksuser.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:
-
AuthContextReact context withuseAuth()hook - App initialization: calls
GET /api/auth/meto determine state (setup required / login / authenticated) - Components use
useAuth()for user info, login/logout/setup actions
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:
-
App initialization calls
GET /api/auth/meto determine the current state - If
setupRequired: trueis returned (no users exist), theAuthGuardautomatically redirects to/setup - The user fills out the setup form (email, display name, password)
- On submission, the client calls
POST /api/auth/setupwith the account details - After successful setup, the user is redirected to
/loginto sign in with the new credentials
Server-side protection:
- The
POST /api/auth/setupendpoint is only accessible when no users exist in the database - If users already exist, it returns
403 FORBIDDENwith error codeSETUP_COMPLETE - The endpoint validates password strength (minimum 12 characters)
- After creating the admin account, it returns
201 Createdwith 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(andsetupRequired: 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.
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).
- SQLite stored at
/app/data/cornerstone.dbinside the container (configurable viaDATABASE_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
The database connection lifecycle is managed by a Fastify plugin (server/src/plugins/db.ts):
- Startup: Opens a better-sqlite3 connection, enables WAL mode, runs pending migrations, creates a Drizzle ORM instance
-
Request handling: All routes access the database via
fastify.db(Drizzle ORM instance with full schema type inference) -
Raw access: The underlying better-sqlite3 connection is available via
fastify.db.$clientwhen needed for pragmas or raw SQL -
Shutdown: The
onClosehook 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.
All API errors are handled by a centralized Fastify error handler plugin (ADR-009). The pattern works as follows:
-
Route handlers and services throw
AppErrorsubclasses -- e.g.,throw new NotFoundError('User not found'). They never constructApiErrorResponseobjects directly. -
The
errorHandlerplugin catches all errors and formats them into the standardApiErrorResponseshape ({ error: { code, message, details? } }). -
AJV schema validation errors are handled automatically -- Fastify's built-in JSON schema validation produces validation errors that the plugin normalizes into
VALIDATION_ERRORresponses with field-level details. -
Unknown errors are sanitized in production -- Any error that is not an
AppErroror AJV validation error is returned asINTERNAL_ERROR(500). In production mode, the message is replaced with a generic string to prevent information leakage.
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.
- 4xx errors are logged at
warnlevel (client mistakes, not server failures) - 5xx errors are logged at
errorlevel (genuine server failures for alerting) - All logging uses Fastify's
request.logfor request-scoped context
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).
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.
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.hiddenCardspreference 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,
ThemeContextchecks for athemepreference via the API. If none exists butlocalStoragehas a legacy theme value, it migrates the value to the preferences API and clearslocalStorage.
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.
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_entriestable with a JSONmetadatacolumn 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 asdocument_linksandphotos). No FK constraint — diary entries intentionally survive source entity deletion. -
Fire-and-forget auto events: The
diaryEventServiceis 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
photostable withentity_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).
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:
- Server preference (user_preferences API, key
locale) - localStorage key
locale(instant hydration) - Browser
navigator.language(mapped:de*->de, else ->en) - 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>
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.
- In production, Fastify serves the Webpack-built client from
client/dist/ - SPA fallback: any non-
/api/route servesindex.html - In development, Webpack dev server (port 5173) proxies
/api/*to Fastify (port 3000)
All configuration is via environment variables:
| 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) |
| 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_PROXY — request.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.
| 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.
| 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.
| 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.
- 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
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.
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.
-
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=autoon a machine with 4+ cores. -
Worker recycling overhead: The
--workerIdleMemoryLimitflag 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.
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:
-
Remove
--max-old-space-size=2048from all three scripts (let V8 use its default heap limit) -
Remove
--maxWorkers=2from all three scripts (let Jest auto-detect the optimal worker count) -
Remove
--workerIdleMemoryLimit=300MBfrom all three scripts (or raise to512MBif jsdom memory leaks are still a concern) -
Keep
--experimental-vm-modules-- this is required for ESM support and is unrelated to memory
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
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. |